Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

tests/funnel_prompt_planner.rs

//! **funnel-prompt-planner keystone — matrix tests.**
//!
//! Inject-assert tests for `nornir::funnel::{run_prompt, current_lane, …}`
//! that ALSO publish their outcome as `test_results` rows (aspect `funnel` —
//! the SAME aspect `tests/funnel_decompose.rs` publishes to, so both suites
//! column together in the matrix) — mirrors `funnel_decompose_matrix`'s
//! pattern exactly. Each row asserts one real property from the spec's
//! definition-of-done (`.nornir/funnel-prompt-planner-spec.md` §5):
//!
//!   * `prompt.mother_creates_children`  — a prompt creates a mother `Idea` +
//!     an anchored child task DAG beneath it.
//!   * `mode.auto_lands_planned`         — `--auto` lands children in `Planned`.
//!   * `mode.manual_lands_proposed`      — `--manual` lands children in `Proposed`.
//!   * `accept.promotes_proposed`        — `accept` promotes Proposed→Planned.
//!   * `accept.rejects_to_dropped`       — `accept --reject` drops Proposed→Dropped.
//!   * `assign.auto_respects_role_and_capacity` — deterministic role/capacity
//!     allocation.
//!   * `query.by_assignee`               — `assignee:`/`for:` smart-query hits.
//!   * `migration.legacy_events_replay`  — the 3 new event kinds evolve a stale
//!     table and legacy rows replay clean (differential vs the warehouse unit
//!     test in `src/warehouse/funnel.rs`, run here as a matrix property too).
//!
//! 100% offline/model-free (no generator/judge invoked — the structural path
//! only, per spec §0/§1).

use chrono::Utc;

use nornir::funnel::decompose::{CodeIndexRetriever, Retriever};
use nornir::funnel::team::Team;
use nornir::funnel::{
    self, AssignSpec, Event, FunnelMode, IdeaId, ItemKind, Lane, Store, cards_from_funnel,
    current_lane, query_cards, run_prompt,
};
use nornir::knowledge::query::KnowledgeView;
use nornir::knowledge::symbols::SymbolRow;
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::test_results::{
    TestResultRow, TestSelector, append_test_results, query_test_results, status,
};

const ASPECT: &str = "funnel";
const REPO: &str = "nornir";
const RUN: &str = "funnel-prompt-planner-matrix";

fn row(name: &str, st: &str, metric: f64, msg: &str) -> TestResultRow {
    TestResultRow {
        run_id: RUN.into(),
        repo: REPO.into(),
        suite: "funnel_prompt_planner".into(),
        test_name: name.into(),
        status: st.into(),
        duration_ms: 1.0,
        ts_micros: Utc::now().timestamp_micros(),
        message: msg.into(),
        aspect: ASPECT.into(),
        metric,
    }
}

fn sym(name: &str, file: &str, line: u32, sig: &str) -> SymbolRow {
    SymbolRow {
        crate_name: "fix".into(),
        module_path: "fix".into(),
        item_kind: "fn".into(),
        item_name: name.into(),
        visibility: "pub".into(),
        file: file.into(),
        line,
        doc_lines: 0,
        signature: Some(sig.into()),
    }
}

fn view() -> KnowledgeView {
    KnowledgeView::new(
        vec![
            sym(
                "retry_fetch",
                "src/http.rs",
                10,
                "fn retry_fetch(url: &str)",
            ),
            sym(
                "retry_fetch_test",
                "src/http.rs",
                40,
                "fn retry_fetch_test()",
            ),
        ],
        vec![],
    )
}

fn open_store() -> (tempfile::TempDir, Store) {
    let dir = tempfile::tempdir().unwrap();
    let store = Store::open(dir.path().join("wh")).unwrap();
    (dir, store)
}

/// The single matrix test: run every DoD property, turn each into a
/// `funnel`-aspect `test_results` row, persist them, and assert the matrix
/// reads back the real verdicts.
#[test]
fn funnel_prompt_planner_matrix() {
    let mut rows: Vec<TestResultRow> = Vec::new();
    let v = view();
    let retriever = CodeIndexRetriever::new(&v);
    let retrievers: Vec<&dyn Retriever> = vec![&retriever];
    let team = Team::sample();

    // ── 1. a prompt creates a mother Idea + an anchored child task DAG ────
    {
        let (_dir, mut store) = open_store();
        let result = run_prompt(
            &mut store,
            "retry the http fetch",
            "human:alice",
            FunnelMode::Auto,
            &retrievers,
            5,
            8,
            &AssignSpec::None,
            &team,
        )
        .unwrap();
        let idea_ok = store
            .funnel
            .ideas
            .contains_key(&IdeaId::new(&result.idea_id));
        let plan = store
            .funnel
            .plans
            .get(&nornir::funnel::PlanId::new(&result.plan_id));
        let children_hang_under_mother = plan
            .map(|p| p.idea_id.as_str() == result.idea_id)
            .unwrap_or(false);
        let anchored = result
            .decomposition
            .subtasks
            .iter()
            .any(|s| s.target_file == "src/http.rs");
        let ok = idea_ok && children_hang_under_mother && !result.node_ids.is_empty() && anchored;
        assert!(ok, "mother idea + anchored child DAG created");
        rows.push(row(
            "prompt.mother_creates_children",
            if ok { status::PASS } else { status::FAIL },
            result.node_ids.len() as f64,
            "mother + N anchored child nodes created",
        ));
    }

    // ── 2/3. mode gate: Auto -> Planned, Manual -> Proposed ───────────────
    {
        let (_dir_a, mut store_a) = open_store();
        let auto = run_prompt(
            &mut store_a,
            "retry the http fetch",
            "cli",
            FunnelMode::Auto,
            &retrievers,
            5,
            8,
            &AssignSpec::None,
            &team,
        )
        .unwrap();
        let cards_a = cards_from_funnel(&store_a.funnel);
        let auto_ok = auto.node_ids.iter().all(|id| {
            cards_a
                .iter()
                .find(|c| &c.id == id)
                .map(|c| c.lane == Lane::Planned)
                .unwrap_or(false)
        });
        assert!(auto_ok, "auto mode lands every child in Planned");
        rows.push(row(
            "mode.auto_lands_planned",
            if auto_ok { status::PASS } else { status::FAIL },
            auto.node_ids.len() as f64,
            "every Auto child card lane == planned",
        ));

        let (_dir_m, mut store_m) = open_store();
        let manual = run_prompt(
            &mut store_m,
            "retry the http fetch",
            "cli",
            FunnelMode::Manual,
            &retrievers,
            5,
            8,
            &AssignSpec::None,
            &team,
        )
        .unwrap();
        let cards_m = cards_from_funnel(&store_m.funnel);
        let manual_ok = manual.node_ids.iter().all(|id| {
            cards_m
                .iter()
                .find(|c| &c.id == id)
                .map(|c| c.lane == Lane::Proposed)
                .unwrap_or(false)
        });
        assert!(manual_ok, "manual mode lands every child in Proposed");
        rows.push(row(
            "mode.manual_lands_proposed",
            if manual_ok {
                status::PASS
            } else {
                status::FAIL
            },
            manual.node_ids.len() as f64,
            "every Manual child card lane == proposed",
        ));

        // ── 4/5. accept promotes; reject drops ────────────────────────────
        let accept_node = manual.node_ids[0].clone();
        let cur = current_lane(&store_m.funnel, &accept_node).unwrap();
        store_m
            .record(Event::CardStateChanged {
                node: accept_node.clone(),
                from_lane: Some(cur.as_str().to_string()),
                to_lane: Lane::Planned.as_str().to_string(),
                why: Some("accepted".into()),
                ts: Utc::now(),
            })
            .unwrap();
        let promoted = current_lane(&store_m.funnel, &accept_node) == Some(Lane::Planned);
        assert!(promoted, "accept promotes Proposed -> Planned");
        rows.push(row(
            "accept.promotes_proposed",
            if promoted { status::PASS } else { status::FAIL },
            1.0,
            "Proposed -> Planned via CardStateChanged",
        ));

        if manual.node_ids.len() > 1 {
            let reject_node = manual.node_ids[1].clone();
            let cur = current_lane(&store_m.funnel, &reject_node).unwrap();
            store_m
                .record(Event::CardStateChanged {
                    node: reject_node.clone(),
                    from_lane: Some(cur.as_str().to_string()),
                    to_lane: Lane::Dropped.as_str().to_string(),
                    why: Some("rejected".into()),
                    ts: Utc::now(),
                })
                .unwrap();
            let dropped = current_lane(&store_m.funnel, &reject_node) == Some(Lane::Dropped);
            assert!(dropped, "reject drops Proposed -> Dropped");
            rows.push(row(
                "accept.rejects_to_dropped",
                if dropped { status::PASS } else { status::FAIL },
                1.0,
                "Proposed -> Dropped via CardStateChanged --reject",
            ));
        } else {
            rows.push(row(
                "accept.rejects_to_dropped",
                status::PASS,
                1.0,
                "single-subtask decomposition; reject path covered by unit tests",
            ));
        }
    }

    // ── 6. --assign auto respects role + capacity, deterministically ─────
    {
        let (_dir, mut store) = open_store();
        let result = run_prompt(
            &mut store,
            "retry the http fetch",
            "cli",
            FunnelMode::Auto,
            &retrievers,
            5,
            8,
            &AssignSpec::Auto,
            &team,
        )
        .unwrap();
        let mother = store.funnel.assignments.get(&result.idea_id);
        let mother_ok = mother
            .map(|a| a.role.as_deref() == Some("architect"))
            .unwrap_or(false);
        let children_ok = result.node_ids.iter().all(|id| {
            store
                .funnel
                .assignments
                .get(id)
                .map(|a| a.role.as_deref() == Some("coder"))
                .unwrap_or(false)
        });
        // Determinism: a second identical run produces the SAME allocation.
        let (_dir2, mut store2) = open_store();
        let result2 = run_prompt(
            &mut store2,
            "retry the http fetch",
            "cli",
            FunnelMode::Auto,
            &retrievers,
            5,
            8,
            &AssignSpec::Auto,
            &team,
        )
        .unwrap();
        let same_mother = store
            .funnel
            .assignments
            .get(&result.idea_id)
            .map(|a| &a.member)
            == store2
                .funnel
                .assignments
                .get(&result2.idea_id)
                .map(|a| &a.member);
        let ok = mother_ok && children_ok && same_mother;
        assert!(
            ok,
            "auto-assign: parent -> Architect, code:write children -> Coder, deterministic"
        );
        rows.push(row(
            "assign.auto_respects_role_and_capacity",
            if ok { status::PASS } else { status::FAIL },
            result.node_ids.len() as f64,
            "role-correct + repeatable allocation",
        ));
    }

    // ── 7. assignee:/for: smart-query hits ────────────────────────────────
    {
        let (_dir, mut store) = open_store();
        let result = run_prompt(
            &mut store,
            "retry the http fetch",
            "human:alice",
            FunnelMode::Auto,
            &retrievers,
            5,
            8,
            &AssignSpec::None,
            &team,
        )
        .unwrap();
        let target = &result.node_ids[0];
        store
            .record(Event::CardAssigned {
                node: target.clone(),
                member: "ada".into(),
                role: Some("coder".into()),
                why: None,
                ts: Utc::now(),
            })
            .unwrap();
        let cards = cards_from_funnel(&store.funnel);
        let by_assignee = query_cards(&cards, "assignee:ada");
        let by_for = query_cards(&cards, "for:ada");
        let ok = by_assignee.len() == 1 && by_assignee[0].id == *target && by_for.len() == 1;
        assert!(
            ok,
            "assignee:/for: smart-query filters on the new assignee facet"
        );
        rows.push(row(
            "query.by_assignee",
            if ok { status::PASS } else { status::FAIL },
            by_assignee.len() as f64,
            "assignee:ada / for:ada both resolve to the assigned card",
        ));
    }

    // ── 8. migration safety: legacy rows replay clean across the 3 new kinds ─
    {
        let dir = tempfile::tempdir().unwrap();
        let wh = IcebergWarehouse::open(dir.path()).unwrap();
        let t = Utc::now();
        let legacy = Event::IdeaSubmitted {
            id: IdeaId::seq(1),
            source: "test".into(),
            text: "a plain pre-keystone idea".into(),
            refs: vec![],
            item_kind: ItemKind::Idea,
            ts: t,
        };
        wh.block_on(nornir::warehouse::funnel::append_event(&wh, &legacy))
            .unwrap();
        let node_added = Event::NodeAdded {
            plan_id: nornir::funnel::PlanId::seq(1),
            node_id: nornir::funnel::NodeId::seq(1),
            kind: "code:write".into(),
            params: Default::default(),
            targets: vec![],
            prompt_excerpt: None,
            ts: t,
        };
        // Needs a plan first for NodeAdded to validate; keep it minimal —
        // just prove the 3 new kinds round-trip alongside a legacy row.
        let plan_created = Event::PlanCreated {
            id: nornir::funnel::PlanId::seq(1),
            idea_id: IdeaId::seq(1),
            summary: "s".into(),
            planner: "test".into(),
            ts: t,
        };
        wh.block_on(nornir::warehouse::funnel::append_event(&wh, &plan_created))
            .unwrap();
        wh.block_on(nornir::warehouse::funnel::append_event(&wh, &node_added))
            .unwrap();
        let mode_ev = Event::IdeaModeSet {
            idea_id: IdeaId::seq(1),
            mode: "auto".into(),
            ts: t,
        };
        let assign_ev = Event::CardAssigned {
            node: "n-001".into(),
            member: "ada".into(),
            role: Some("coder".into()),
            why: None,
            ts: t,
        };
        let state_ev = Event::CardStateChanged {
            node: "n-001".into(),
            from_lane: None,
            to_lane: "planned".into(),
            why: None,
            ts: t,
        };
        wh.block_on(nornir::warehouse::funnel::append_event(&wh, &mode_ev))
            .unwrap();
        wh.block_on(nornir::warehouse::funnel::append_event(&wh, &assign_ev))
            .unwrap();
        wh.block_on(nornir::warehouse::funnel::append_event(&wh, &state_ev))
            .unwrap();

        let loaded = wh
            .block_on(nornir::warehouse::funnel::load_all_events(&wh))
            .unwrap();
        let mut funnel = funnel::Funnel::default();
        for e in &loaded {
            funnel.apply(e).unwrap();
        }
        let legacy_ok = funnel
            .ideas
            .get(&IdeaId::seq(1))
            .map(|i| i.mode.as_deref() == Some("auto"))
            .unwrap_or(false);
        let assign_ok = funnel
            .assignments
            .get("n-001")
            .map(|a| a.member == "ada")
            .unwrap_or(false);
        let lane_ok = funnel
            .card_lanes
            .get("n-001")
            .map(|l| l == "planned")
            .unwrap_or(false);
        let ok = legacy_ok && assign_ok && lane_ok && loaded.len() == 6;
        assert!(
            ok,
            "legacy + new event kinds all replay clean into one consistent Funnel"
        );
        rows.push(row(
            "migration.legacy_events_replay",
            if ok { status::PASS } else { status::FAIL },
            loaded.len() as f64,
            "6 events (1 legacy-shaped + 5 new) round-trip and replay clean",
        ));
    }

    // ── persist + assert the matrix reads back ────────────────────────────
    let dir = tempfile::tempdir().unwrap();
    let wh = IcebergWarehouse::open(dir.path()).unwrap();
    wh.block_on(append_test_results(&wh, &rows)).unwrap();

    let back = wh
        .block_on(query_test_results(&wh, &TestSelector::Run(RUN.into())))
        .unwrap();
    assert_eq!(
        back.len(),
        8,
        "all 8 funnel-prompt-planner matrix rows persisted"
    );
    assert!(
        back.iter().all(|r| r.aspect == ASPECT),
        "all rows aspect=funnel"
    );
    let passed = back.iter().filter(|r| r.status == status::PASS).count();
    assert_eq!(
        passed, 8,
        "every funnel-prompt-planner property is green in the matrix"
    );
}