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"));
}
}