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

nordisk/nornir

src/funnel/planner.rs

//! **Plan without big AI** — derive a funnel plan from the *component
//! dependency graph* instead of hand-authoring plan nodes.
//!
//! When a funnel item (idea / error / test) lands, we want a plan, but we don't
//! want a large model to *write* one. The dep-graph already encodes the right
//! plan: a change to a component must be re-done across every component that
//! (transitively) depends on it — the **blast radius** — in build order. So the
//! only judgement call is "which component(s) does this item text touch?", and
//! even that is a small, local decision. This module wires the two together:
//!
//!  1. [`propose_components`] — map the item text → component name(s). It tries,
//!     in order:
//!       a. the local **`ModelCaller`** (e.g. [`OllamaCaller`](crate::warehouse::agent_model_runs::OllamaCaller))
//!          — a tiny prompt that, given the input + the component list, returns
//!          the best-match component name(s);
//!       b. the **`Embedder`** (jina, `src/vector`) — embed the input + each
//!          component's name, pick the nearest by cosine;
//!       c. neither available / confident → return the **full component list**
//!          for the user to pick (the CLI/viz pick-list).
//!     The LLM/embedder are *optional*: with neither, you always get the
//!     pick-list. The LLM only maps text→component — it never plans.
//!
//!  2. [`affected_components`] — given the touched component(s), the dep-graph
//!     computes the transitive **dependents** (blast radius) in topological
//!     (build) order. This is the plan's spine; the dep-graph does the planning.
//!
//!  3. [`plan_from_component`] — weave the affected set into the funnel as a
//!     real [`Plan`](crate::funnel::Plan): one node per affected component in
//!     topo order (`targets = [component]`, `node_kind` from the item kind),
//!     chained with dependency edges so `topo_ready` dispatches them deps-first.
//!     Appends the existing `PlanCreated` / `NodeAdded` / `EdgeAdded` events.

use anyhow::{Result, anyhow};
use chrono::Utc;

use crate::warehouse::agent_model_runs::ModelCaller;
use crate::warehouse::dep_graph::WorkspaceGraph;

use super::event::{Event, ItemKind, PlanStatus};
use super::ids::{IdeaId, NodeId, PlanId};
use super::store::Store;

/// The embedder seam used by the proposer's fallback leg. Aliased to the real
/// `vector::store::Embedder` when the `vector` feature is on, and to an
/// uninhabitable placeholder when it is off — so `propose_components` keeps one
/// signature regardless of features (callers pass `None` when `vector` is off).
#[cfg(feature = "vector")]
pub use crate::vector::store::Embedder as ProposerEmbedder;

/// Placeholder embedder seam for builds without the `vector` feature. It has no
/// callable surface (the embedder leg is `#[cfg]`-compiled out), so passing a
/// `&dyn ProposerEmbedder` is impossible — the proposer always degrades to the
/// LLM leg then the pick-list.
#[cfg(not(feature = "vector"))]
pub trait ProposerEmbedder {}

/// The result of mapping a funnel item's text to component(s).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentProposal {
    /// The component name(s) the item is judged to touch. Non-empty + confident
    /// when an LLM/embedder matched; empty when we fell back to the pick-list.
    pub picks: Vec<String>,
    /// Did a backend (LLM or embedder) confidently pick? `false` ⇒ `picks` is
    /// empty and the caller must show `all` as a pick-list.
    pub confident: bool,
    /// Every component the workspace knows about — the pick-list the user
    /// chooses from when no backend was confident (always populated).
    pub all: Vec<String>,
    /// Which path produced this proposal (for the viz `state_json` + CLI trace).
    pub source: ProposalSource,
}

/// Which leg of the fallback chain produced a [`ComponentProposal`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProposalSource {
    /// The local `ModelCaller` (ollama / mock) named the component(s).
    Llm,
    /// The `Embedder` (cosine over component names) named the component(s).
    Embedder,
    /// No backend was available/confident — the full pick-list is returned.
    PickList,
}

impl ProposalSource {
    pub fn as_str(&self) -> &'static str {
        match self {
            ProposalSource::Llm => "llm",
            ProposalSource::Embedder => "embedder",
            ProposalSource::PickList => "pick_list",
        }
    }
}

impl ComponentProposal {
    /// Readable shape for the viz `state_json` + CLI JSON (LAW 6).
    pub fn to_json(&self) -> serde_json::Value {
        serde_json::json!({
            "picks": self.picks,
            "confident": self.confident,
            "source": self.source.as_str(),
            "all": self.all,
        })
    }
}

/// The full sorted component list of a workspace graph (= the repos it knows,
/// from `facts` and edge endpoints — so a snapshot-derived query-only graph
/// still lists its components). See [`WorkspaceGraph::component_names`].
pub fn component_list(graph: &WorkspaceGraph) -> Vec<String> {
    graph.component_names()
}

/// Map an item's `input` text → the component(s) it touches, using the fallback
/// chain LLM → embedder → pick-list. `llm`/`embedder` are optional; pass `None`
/// for both to force the pick-list. Always returns the full `all` list so the
/// caller can offer a pick even on a confident hit.
///
/// The LLM is asked a constrained question (return ONLY a component name from
/// this list); we keep only the names that are actually in the graph, so a
/// hallucinated name degrades to "not confident" → next leg, never a bogus plan.
pub fn propose_components(
    input: &str,
    graph: &WorkspaceGraph,
    llm: Option<&dyn ModelCaller>,
    embedder: Option<&dyn ProposerEmbedder>,
) -> ComponentProposal {
    let all = component_list(graph);

    // (a) LLM leg.
    if let Some(caller) = llm {
        if let Some(picks) = llm_pick(caller, input, &all) {
            if !picks.is_empty() {
                return ComponentProposal {
                    picks,
                    confident: true,
                    all,
                    source: ProposalSource::Llm,
                };
            }
        }
    }

    // (b) Embedder leg (only compiled with the `vector` feature; without it the
    // `ProposerEmbedder` placeholder has no `embed`, so this leg is absent and
    // we fall straight through to the pick-list).
    #[cfg(feature = "vector")]
    if let Some(emb) = embedder {
        if let Some(pick) = embed_pick(emb, input, &all) {
            return ComponentProposal {
                picks: vec![pick],
                confident: true,
                all,
                source: ProposalSource::Embedder,
            };
        }
    }
    #[cfg(not(feature = "vector"))]
    let _ = &embedder;

    // (c) Pick-list fallback.
    ComponentProposal {
        picks: Vec::new(),
        confident: false,
        all,
        source: ProposalSource::PickList,
    }
}

/// Ask a `ModelCaller` to name the matching component(s). Returns the names that
/// are actually in `components` (filtering hallucinations), or `None` if the
/// call errored. An empty-but-Ok answer maps to `Some(vec![])` so the caller
/// treats "the model declined" as not-confident and falls through.
fn llm_pick(caller: &dyn ModelCaller, input: &str, components: &[String]) -> Option<Vec<String>> {
    let prompt = build_classify_prompt(input, components);
    let answer = caller.call("planner", "classify", &prompt).ok()?;
    Some(extract_components(&answer.output, components))
}

/// The constrained classification prompt: list the components, give the input,
/// demand ONLY component name(s) from the list back (comma-separated). Kept
/// deterministic + parseable so a `MockCaller` in tests can answer it.
pub fn build_classify_prompt(input: &str, components: &[String]) -> String {
    let mut p = String::new();
    p.push_str(
        "You map a change request to the software component(s) it touches.\n\
         Reply with ONLY the matching component name(s) from this exact list, \
         comma-separated, nothing else.\n\nComponents:\n",
    );
    for c in components {
        p.push_str("- ");
        p.push_str(c);
        p.push('\n');
    }
    p.push_str("\nChange request:\n");
    p.push_str(input);
    p.push_str("\n\nComponent(s):");
    p
}

/// Parse a model's free-text answer into the subset of `components` it named.
/// Tolerant: splits on commas/whitespace/newlines, lower-cases, and keeps only
/// tokens that are an exact (case-insensitive) component name — so chatter
/// around the answer (`"The component is foo."`) still yields `[foo]`, while a
/// hallucinated name is dropped (→ not confident).
pub fn extract_components(answer: &str, components: &[String]) -> Vec<String> {
    let lc: std::collections::BTreeMap<String, &String> = components
        .iter()
        .map(|c| (c.to_ascii_lowercase(), c))
        .collect();
    let mut picks: Vec<String> = Vec::new();
    let cleaned: String = answer
        .chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                ' '
            }
        })
        .collect();
    for tok in cleaned.split_whitespace() {
        if let Some(name) = lc.get(&tok.to_ascii_lowercase()) {
            if !picks.contains(*name) {
                picks.push((*name).clone());
            }
        }
    }
    picks
}

/// Embed the input + each component name and pick the nearest by cosine.
/// Returns `None` if embedding fails or there are no components. (The embedder
/// L2-normalizes, so cosine is the dot product; we still normalize defensively.)
#[cfg(feature = "vector")]
fn embed_pick(
    emb: &dyn crate::vector::store::Embedder,
    input: &str,
    components: &[String],
) -> Option<String> {
    if components.is_empty() {
        return None;
    }
    let mut texts: Vec<&str> = Vec::with_capacity(components.len() + 1);
    texts.push(input);
    texts.extend(components.iter().map(|s| s.as_str()));
    let vecs = emb.embed(&texts).ok()?;
    if vecs.len() != texts.len() {
        return None;
    }
    let q = &vecs[0];
    let mut best: Option<(usize, f32)> = None;
    for (i, comp_vec) in vecs[1..].iter().enumerate() {
        let s = cosine(q, comp_vec);
        if best.map(|(_, bs)| s > bs).unwrap_or(true) {
            best = Some((i, s));
        }
    }
    best.map(|(i, _)| components[i].clone())
}

/// Cosine similarity of two equal-length vectors (0.0 on a zero/empty vector).
#[cfg(feature = "vector")]
fn cosine(a: &[f32], b: &[f32]) -> f32 {
    if a.len() != b.len() || a.is_empty() {
        return 0.0;
    }
    let mut dot = 0.0f32;
    let mut na = 0.0f32;
    let mut nb = 0.0f32;
    for (x, y) in a.iter().zip(b.iter()) {
        dot += x * y;
        na += x * x;
        nb += y * y;
    }
    if na == 0.0 || nb == 0.0 {
        0.0
    } else {
        dot / (na.sqrt() * nb.sqrt())
    }
}

/// The topological **affected set** for a change to `touched`: the touched
/// component(s) themselves ∪ everything that (transitively) depends on them
/// (the blast radius), in build order (dependencies first). This is exactly the
/// set a plan must re-do, in the order it must be done. Pure dep-graph logic —
/// no LLM. Thin wrapper over [`WorkspaceGraph::affected_by_change`] so the
/// planner names what it does.
pub fn affected_components(graph: &WorkspaceGraph, touched: &[String]) -> Vec<String> {
    graph.affected_by_change(touched)
}

/// The plan the dep-graph derived for an item, before it is woven into the
/// funnel — returned so the CLI/viz can preview it without committing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedPlan {
    /// The component(s) the item was judged to touch.
    pub touched: Vec<String>,
    /// The topological affected set (blast radius, build order) — one plan node
    /// each.
    pub affected: Vec<String>,
    /// The `node_kind` each node will carry (from the item kind).
    pub node_kind: String,
}

impl DerivedPlan {
    pub fn to_json(&self) -> serde_json::Value {
        serde_json::json!({
            "touched": self.touched,
            "affected": self.affected,
            "node_kind": self.node_kind,
        })
    }
}

/// Derive (but do NOT record) the plan for an item of `kind` touching
/// `touched`: compute the topo affected set + the node kind. Pure — a CLI/viz
/// can show it before the user commits.
pub fn derive_plan(graph: &WorkspaceGraph, touched: &[String], kind: ItemKind) -> DerivedPlan {
    DerivedPlan {
        touched: touched.to_vec(),
        affected: affected_components(graph, touched),
        node_kind: kind.node_kind().to_string(),
    }
}

/// Weave an auto-derived plan into the funnel for `idea_id`: compute the topo
/// affected set for `touched`, then append one [`Plan`](crate::funnel::Plan)
/// with one node per affected component (in build order, `targets =
/// [component]`, kind from the item) chained by dependency edges so
/// `topo_ready` dispatches them dependencies-first. Returns the new
/// [`PlanId`].
///
/// The dep-graph does the planning here — this function only materialises its
/// output as the existing funnel events. The plan is created `Active` so the
/// dispatcher picks it up immediately. The item kind defaults to `Idea`'s node
/// kind if the idea is unknown (it normally is known — the caller just
/// submitted it).
pub fn plan_from_component(
    store: &mut Store,
    idea_id: &IdeaId,
    touched: &[String],
    graph: &WorkspaceGraph,
) -> Result<PlanId> {
    if touched.is_empty() {
        return Err(anyhow!(
            "plan_from_component: no touched component(s) given"
        ));
    }
    // Reject components the dep graph doesn't know — otherwise the blast radius
    // is just the bogus name echoed back and we'd plan a node against nothing.
    let unknown: Vec<&String> = touched.iter().filter(|c| !graph.has_component(c)).collect();
    if !unknown.is_empty() {
        let known = component_list(graph).join(", ");
        return Err(anyhow!(
            "plan_from_component: unknown component(s) {unknown:?}; known: {known}"
        ));
    }
    let kind = store
        .funnel
        .ideas
        .get(idea_id)
        .map(|i| i.item_kind)
        .unwrap_or(ItemKind::Idea);
    let node_kind = kind.node_kind().to_string();

    let affected = affected_components(graph, touched);
    if affected.is_empty() {
        return Err(anyhow!(
            "plan_from_component: component(s) {touched:?} are not in the dep graph"
        ));
    }

    // A microsecond-stepped clock so the events replay in the exact order we
    // emit them (Iceberg scan order isn't stable; the store sorts by `ts`).
    let base = Utc::now();
    let mut tick: i64 = 0;
    let mut at = || {
        let t = base + chrono::Duration::microseconds(tick);
        tick += 1;
        t
    };

    let plan_id = PlanId::seq(store.funnel.next_plan);
    let summary = format!(
        "auto-plan ({}) for {} → {} affected component(s)",
        kind.as_str(),
        touched.join(", "),
        affected.len(),
    );
    store.record(Event::PlanCreated {
        id: plan_id.clone(),
        idea_id: idea_id.clone(),
        summary,
        planner: "dep-graph".into(),
        ts: at(),
    })?;
    store.record(Event::PlanStatusChanged {
        plan_id: plan_id.clone(),
        status: PlanStatus::Active,
        why: Some("auto-planned from component dep graph".into()),
        ts: at(),
    })?;

    // One node per affected component, in build order. Edge prev→cur chains them
    // so a dependent component's node only becomes Ready once its dependency's
    // node is Done — the topological dispatch order the blast radius demands.
    let mut prev: Option<NodeId> = None;
    for comp in &affected {
        let node_id = NodeId::seq(store.funnel.next_node);
        let mut params = serde_json::Map::new();
        params.insert(
            "title".into(),
            serde_json::Value::String(format!("{node_kind} {comp}")),
        );
        params.insert("component".into(), serde_json::Value::String(comp.clone()));
        store.record(Event::NodeAdded {
            plan_id: plan_id.clone(),
            node_id: node_id.clone(),
            kind: node_kind.clone(),
            params,
            targets: vec![comp.clone()],
            prompt_excerpt: None,
            ts: at(),
        })?;
        if let Some(from) = prev {
            store.record(Event::EdgeAdded {
                plan_id: plan_id.clone(),
                from_node: from,
                to_node: node_id.clone(),
                ts: at(),
            })?;
        }
        prev = Some(node_id);
    }

    store.funnel.promote_ready();
    Ok(plan_id)
}

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(feature = "vector")]
    use crate::vector::store::{Embedder, ModelProfile};
    use crate::warehouse::agent_model_runs::{MockCaller, ModelAnswer};
    use crate::warehouse::dep_graph::{
        CrossRepoEdge, RepoFacts, WorkspaceGraph, topo_order_from_edges,
    };
    use std::collections::{BTreeMap, BTreeSet};
    use std::path::PathBuf;

    fn facts(name: &str, produces: &[&str], consumes: &[&str]) -> RepoFacts {
        RepoFacts {
            name: name.to_string(),
            root: PathBuf::from("/dev/null"),
            produces: produces.iter().map(|s| s.to_string()).collect(),
            consumes: consumes.iter().map(|s| s.to_string()).collect(),
            ..Default::default()
        }
    }
    fn edge(from: &str, to: &str, via: &[&str]) -> CrossRepoEdge {
        CrossRepoEdge::normal(
            from,
            to,
            via.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>(),
        )
    }

    /// Diamond:  app → liba → util,  app → libb → util.
    fn diamond() -> WorkspaceGraph {
        let facts = [
            facts("app", &["app_c"], &["a_c", "b_c"]),
            facts("liba", &["a_c"], &["util_c"]),
            facts("libb", &["b_c"], &["util_c"]),
            facts("util", &["util_c"], &[]),
        ];
        let mut fmap = BTreeMap::new();
        for f in facts {
            fmap.insert(f.name.clone(), f);
        }
        WorkspaceGraph::from_query_parts(
            fmap,
            vec![
                edge("app", "liba", &["a_c"]),
                edge("app", "libb", &["b_c"]),
                edge("liba", "util", &["util_c"]),
                edge("libb", "util", &["util_c"]),
            ],
        )
    }

    /// A deterministic mock embedder: embeds a 4-dim one-hot per component name
    /// (and the input is hashed onto the same basis by a keyword). Lets us assert
    /// the embedder leg picks the nearest component without a real ONNX model.
    #[cfg(feature = "vector")]
    struct KeywordEmbedder;
    #[cfg(feature = "vector")]
    impl Embedder for KeywordEmbedder {
        fn profile(&self) -> ModelProfile {
            ModelProfile {
                model_name: "kw".into(),
                weights_sha: "w".into(),
                tokenizer_sha: "t".into(),
                pooling: "mean".into(),
                normalize: true,
                dim: 4,
                dtype: "f32".into(),
            }
        }
        fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
            // axis: util=0, liba=1, libb=2, app=3; the input maps by substring.
            let axis = |t: &str| -> usize {
                let t = t.to_ascii_lowercase();
                if t.contains("util") {
                    0
                } else if t.contains("liba") {
                    1
                } else if t.contains("libb") {
                    2
                } else {
                    3
                }
            };
            Ok(texts
                .iter()
                .map(|t| {
                    let mut v = vec![0.0f32; 4];
                    v[axis(t)] = 1.0;
                    v
                })
                .collect())
        }
    }

    #[test]
    fn component_list_is_sorted_repo_names() {
        let g = diamond();
        assert_eq!(component_list(&g), vec!["app", "liba", "libb", "util"]);
    }

    #[test]
    fn affected_set_is_blast_radius_in_build_order() {
        let g = diamond();
        // Touching `util` must re-do util + liba + libb + app, deps first.
        let affected = affected_components(&g, &["util".to_string()]);
        assert_eq!(
            affected.iter().cloned().collect::<BTreeSet<_>>(),
            ["app", "liba", "libb", "util"]
                .iter()
                .map(|s| s.to_string())
                .collect()
        );
        let pos = |n: &str| affected.iter().position(|x| x == n).unwrap();
        assert!(pos("util") < pos("liba"));
        assert!(pos("util") < pos("libb"));
        assert!(pos("liba") < pos("app"));
        assert!(pos("libb") < pos("app"));
        // Touching a leaf consumer (`app`) affects only itself.
        assert_eq!(
            affected_components(&g, &["app".to_string()]),
            vec!["app".to_string()]
        );
    }

    #[test]
    fn llm_leg_picks_the_named_component() {
        let g = diamond();
        // The mock answers the classify prompt with chatter around the name.
        let caller = MockCaller::new().with_cell(
            "planner",
            "classify",
            ModelAnswer::basic("The component is util.", 1.0, 1, 1, 1.0, 1.0),
        );
        let p = propose_components("speed up the shared util layer", &g, Some(&caller), None);
        assert!(p.confident);
        assert_eq!(p.source, ProposalSource::Llm);
        assert_eq!(p.picks, vec!["util".to_string()]);
        assert_eq!(p.all, vec!["app", "liba", "libb", "util"]);
    }

    #[test]
    fn llm_hallucination_falls_through_to_pick_list() {
        let g = diamond();
        // The model names something not in the graph → dropped → not confident →
        // with no embedder, we get the pick-list.
        let caller = MockCaller::new().with_cell(
            "planner",
            "classify",
            ModelAnswer::basic("ghost-component", 1.0, 1, 1, 1.0, 1.0),
        );
        let p = propose_components("touch the ghost", &g, Some(&caller), None);
        assert!(!p.confident);
        assert_eq!(p.source, ProposalSource::PickList);
        assert!(p.picks.is_empty());
        assert_eq!(p.all, vec!["app", "liba", "libb", "util"]);
    }

    #[cfg(feature = "vector")]
    #[test]
    fn embedder_leg_used_when_llm_absent() {
        let g = diamond();
        // No LLM; the embedder maps the "liba" keyword to the liba axis → picks liba.
        let p = propose_components("rework liba internals", &g, None, Some(&KeywordEmbedder));
        assert!(p.confident);
        assert_eq!(p.source, ProposalSource::Embedder);
        assert_eq!(p.picks, vec!["liba".to_string()]);
    }

    #[test]
    fn pick_list_when_no_backend() {
        let g = diamond();
        let p = propose_components("anything", &g, None, None);
        assert!(!p.confident);
        assert_eq!(p.source, ProposalSource::PickList);
        assert!(p.picks.is_empty());
        assert_eq!(p.all, vec!["app", "liba", "libb", "util"]);
    }

    /// Full inject-assert: seed an idea + a real dep graph, run the LLM leg to
    /// pick `util`, then `plan_from_component` — assert the generated plan has
    /// exactly one node per topo-affected component, in build order, chained by
    /// edges, with the test node-kind + per-node component target.
    #[test]
    fn plan_from_component_generates_topo_plan_nodes() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("wh");
        let mut store = Store::open(&root).unwrap();

        // Submit a TEST idea (so node_kind = test:write).
        let idea = IdeaId::seq(store.funnel.next_idea);
        store
            .record(Event::IdeaSubmitted {
                id: idea.clone(),
                source: "test".into(),
                text: "add tests for the shared util layer".into(),
                refs: vec![],
                item_kind: ItemKind::Test,
                ts: Utc::now(),
            })
            .unwrap();

        let g = diamond();
        // Classify → util via the mock LLM.
        let caller = MockCaller::new().with_cell(
            "planner",
            "classify",
            ModelAnswer::basic("util", 1.0, 1, 1, 1.0, 1.0),
        );
        let prop = propose_components(&store.funnel.ideas[&idea].text, &g, Some(&caller), None);
        assert_eq!(prop.picks, vec!["util".to_string()]);

        let plan_id = plan_from_component(&mut store, &idea, &prop.picks, &g).unwrap();
        let plan = store.funnel.plans.get(&plan_id).expect("plan created");
        assert_eq!(plan.idea_id, idea);
        assert_eq!(plan.status, PlanStatus::Active);

        // One node per affected component (util, liba, libb, app) = 4 nodes.
        assert_eq!(plan.nodes.len(), 4, "one node per affected component");
        // Every node is a test:write node targeting exactly its component.
        let mut targeted: BTreeSet<String> = BTreeSet::new();
        for n in plan.nodes.values() {
            assert_eq!(n.kind, "test:write", "test idea → test:write node kind");
            assert_eq!(n.targets.len(), 1);
            targeted.insert(n.targets[0].clone());
        }
        assert_eq!(
            targeted,
            ["app", "liba", "libb", "util"]
                .iter()
                .map(|s| s.to_string())
                .collect()
        );

        // The chain edges enforce build order: 3 edges for 4 nodes.
        assert_eq!(plan.edges.len(), 3, "nodes chained prev→cur");

        // The first node in build order (util — no in-edges) is Ready; the rest
        // wait on their predecessor.
        let ready: Vec<String> = plan
            .nodes
            .values()
            .filter(|n| n.status == super::super::NodeStatus::Ready)
            .flat_map(|n| n.targets.clone())
            .collect();
        assert_eq!(
            ready,
            vec!["util".to_string()],
            "only the deps-first node is Ready"
        );
    }

    #[test]
    fn plan_from_component_rejects_empty_and_unknown() {
        let dir = tempfile::tempdir().unwrap();
        let mut store = Store::open(dir.path().join("wh")).unwrap();
        let idea = IdeaId::seq(0);
        store
            .record(Event::IdeaSubmitted {
                id: idea.clone(),
                source: "t".into(),
                text: "x".into(),
                refs: vec![],
                item_kind: ItemKind::Idea,
                ts: Utc::now(),
            })
            .unwrap();
        let g = diamond();
        assert!(plan_from_component(&mut store, &idea, &[], &g).is_err());
        assert!(plan_from_component(&mut store, &idea, &["ghost".to_string()], &g).is_err());
    }

    #[test]
    fn derive_plan_uses_item_kind_node_kind() {
        let g = diamond();
        assert_eq!(
            derive_plan(&g, &["liba".to_string()], ItemKind::Error).node_kind,
            "code:fix"
        );
        assert_eq!(
            derive_plan(&g, &["liba".to_string()], ItemKind::Idea).node_kind,
            "code:write"
        );
        let d = derive_plan(&g, &["util".to_string()], ItemKind::Test);
        assert_eq!(d.node_kind, "test:write");
        // The affected set matches the topo blast radius.
        assert_eq!(
            d.affected.iter().cloned().collect::<BTreeSet<_>>(),
            ["app", "liba", "libb", "util"]
                .iter()
                .map(|s| s.to_string())
                .collect()
        );
    }

    /// `topo_order_from_edges` is the spine `affected_components` leans on — a
    /// direct check that an unrelated extra edge doesn't perturb the order.
    #[test]
    fn topo_order_is_stable_for_subset() {
        let edges = vec![edge("liba", "util", &["c"]), edge("app", "liba", &["c"])];
        let repos = vec!["app".to_string(), "liba".to_string(), "util".to_string()];
        let order = topo_order_from_edges(&repos, &edges);
        let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
        assert!(pos("util") < pos("liba"));
        assert!(pos("liba") < pos("app"));
    }
}