use std::collections::{BTreeMap, BTreeSet};
pub type Adj = BTreeMap<String, BTreeSet<String>>;
pub fn tarjan_scc(adj: &Adj) -> Vec<Vec<String>> {
let nodes: Vec<String> = adj.keys().cloned().collect();
let mut index: BTreeMap<String, usize> = BTreeMap::new();
let mut low: BTreeMap<String, usize> = BTreeMap::new();
let mut on_stack: BTreeSet<String> = BTreeSet::new();
let mut stack: Vec<String> = Vec::new();
let mut idx = 0usize;
let mut out: Vec<Vec<String>> = Vec::new();
for start in &nodes {
if index.contains_key(start) {
continue;
}
let mut work: Vec<(String, usize)> = vec![(start.clone(), 0)];
while let Some((v, mut i)) = work.pop() {
if i == 0 {
index.insert(v.clone(), idx);
low.insert(v.clone(), idx);
idx += 1;
stack.push(v.clone());
on_stack.insert(v.clone());
}
let succs: Vec<String> =
adj.get(&v).map(|s| s.iter().cloned().collect()).unwrap_or_default();
let mut recursed = false;
while i < succs.len() {
let w = &succs[i];
if !index.contains_key(w) {
work.push((v.clone(), i + 1));
work.push((w.clone(), 0));
recursed = true;
break;
} else if on_stack.contains(w) {
let lw = index[w];
let lv = low[&v];
low.insert(v.clone(), lv.min(lw));
}
i += 1;
}
if recursed {
continue;
}
if low[&v] == index[&v] {
let mut comp = Vec::new();
while let Some(w) = stack.pop() {
on_stack.remove(&w);
comp.push(w.clone());
if w == v {
break;
}
}
comp.sort();
out.push(comp);
}
if let Some((parent, _)) = work.last() {
let lp = low[parent];
let lv = low[&v];
low.insert(parent.clone(), lp.min(lv));
}
}
}
out
}
pub fn is_self_loop(adj: &Adj, comp: &[String]) -> bool {
comp.len() == 1 && adj.get(&comp[0]).map(|d| d.contains(&comp[0])).unwrap_or(false)
}
pub fn cycles(adj: &Adj) -> Vec<Vec<String>> {
tarjan_scc(adj)
.into_iter()
.filter(|c| c.len() > 1 || is_self_loop(adj, c))
.collect()
}
#[derive(Debug, Clone)]
pub struct Condensation {
pub comps: Vec<Vec<String>>,
pub comp_of: BTreeMap<String, usize>,
pub dag: Vec<BTreeSet<usize>>,
}
pub fn condense(adj: &Adj) -> Condensation {
let comps = tarjan_scc(adj);
let n = comps.len();
let mut comp_of: BTreeMap<String, usize> = BTreeMap::new();
for (i, c) in comps.iter().enumerate() {
for node in c {
comp_of.insert(node.clone(), i);
}
}
let mut dag: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
for (from, tos) in adj {
let Some(&cf) = comp_of.get(from) else { continue };
for to in tos {
if let Some(&ct) = comp_of.get(to) {
if cf != ct {
dag[cf].insert(ct);
}
}
}
}
Condensation { comps, comp_of, dag }
}
impl Condensation {
pub fn must_break(&self) -> Vec<&[String]> {
self.comps.iter().filter(|c| c.len() > 1).map(|c| c.as_slice()).collect()
}
pub fn topo_order(&self) -> Vec<usize> {
let n = self.comps.len();
let mut indeg: Vec<usize> = (0..n).map(|c| self.dag[c].len()).collect();
let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
for (cf, deps) in self.dag.iter().enumerate() {
for &ct in deps {
consumers[ct].push(cf);
}
}
let key = |c: usize| self.comps[c].first().cloned().unwrap_or_default();
let mut ready: BTreeSet<(String, usize)> =
(0..n).filter(|&c| indeg[c] == 0).map(|c| (key(c), c)).collect();
let mut order = Vec::new();
while let Some((k, c)) = ready.iter().next().cloned() {
ready.remove(&(k, c));
order.push(c);
for &con in &consumers[c] {
indeg[con] -= 1;
if indeg[con] == 0 {
ready.insert((key(con), con));
}
}
}
order
}
pub fn waves(&self) -> Vec<Vec<String>> {
let n = self.comps.len();
let mut indeg: Vec<usize> = (0..n).map(|c| self.dag[c].len()).collect();
let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
for (cf, deps) in self.dag.iter().enumerate() {
for &ct in deps {
consumers[ct].push(cf);
}
}
let mut frontier: Vec<usize> = (0..n).filter(|&c| indeg[c] == 0).collect();
let mut waves: Vec<Vec<String>> = Vec::new();
while !frontier.is_empty() {
let mut wave: Vec<String> =
frontier.iter().flat_map(|&c| self.comps[c].iter().cloned()).collect();
wave.sort();
waves.push(wave);
let mut next: Vec<usize> = Vec::new();
for &c in &frontier {
for &con in &consumers[c] {
indeg[con] -= 1;
if indeg[con] == 0 {
next.push(con);
}
}
}
frontier = next;
}
waves
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeClass {
Dev,
Optional,
Normal,
}
impl EdgeClass {
pub fn cost(self, via_len: usize) -> u32 {
match self {
EdgeClass::Dev => 0,
EdgeClass::Optional => 1,
EdgeClass::Normal => 10 + via_len as u32,
}
}
}
#[derive(Debug, Clone)]
pub struct ClassifiedEdge {
pub from: String,
pub to: String,
pub via: Vec<String>,
pub class: EdgeClass,
}
impl ClassifiedEdge {
fn cost(&self) -> u32 {
self.class.cost(self.via.len())
}
fn action(&self) -> String {
match self.class {
EdgeClass::Dev => format!(
"cut `{} → {}` — it's a DEV edge (already non-gating); move it out of the order graph",
self.from, self.to
),
EdgeClass::Optional => format!(
"gate the feature off so `{}` no longer requires `{}` (optional dep)",
self.from, self.to
),
EdgeClass::Normal => {
let via = if self.via.is_empty() {
self.to.clone()
} else {
self.via.join(", ")
};
format!(
"split `{}`: extract `{}` into a leaf crate both can depend on, so `{}` no longer path-depends on `{}`",
self.to, via, self.from, self.to
)
}
}
}
}
#[derive(Debug, Clone)]
pub struct CutSolution {
pub edges: Vec<(String, String)>,
pub cost: u32,
pub actions: Vec<String>,
pub proof_order: Vec<String>,
}
fn topo_of(nodes: &[String], edges: &[(&str, &str)]) -> Option<Vec<String>> {
let set: BTreeSet<&str> = nodes.iter().map(|s| s.as_str()).collect();
let mut indeg: BTreeMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect();
let mut consumers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for &(from, to) in edges {
if set.contains(from) && set.contains(to) {
*indeg.get_mut(from).unwrap() += 1; consumers.entry(to).or_default().push(from);
}
}
let mut ready: BTreeSet<&str> =
indeg.iter().filter(|&(_, &d)| d == 0).map(|(&n, _)| n).collect();
let mut order = Vec::new();
while let Some(&n) = ready.iter().next() {
ready.remove(n);
order.push(n.to_string());
if let Some(cs) = consumers.get(n) {
for &c in cs {
let d = indeg.get_mut(c).unwrap();
*d -= 1;
if *d == 0 {
ready.insert(c);
}
}
}
}
(order.len() == nodes.len()).then_some(order)
}
pub fn min_feedback_arc_set(nodes: &[String], edges: &[ClassifiedEdge]) -> Vec<CutSolution> {
let m = edges.len();
let residual = |mask: u64| -> Vec<(&str, &str)> {
(0..m)
.filter(|i| mask & (1 << i) == 0)
.map(|i| (edges[i].from.as_str(), edges[i].to.as_str()))
.collect()
};
let masks: Vec<u64> = if m <= 20 {
(1u64..(1u64 << m)).collect()
} else {
let mut v = Vec::new();
for a in 0..m {
v.push(1u64 << a);
for b in (a + 1)..m {
v.push((1u64 << a) | (1u64 << b));
for c in (b + 1)..m {
v.push((1u64 << a) | (1u64 << b) | (1u64 << c));
}
}
}
v
};
let mut feasible: Vec<(u64, u32, Vec<String>)> = Vec::new();
for mask in masks {
if let Some(order) = topo_of(nodes, &residual(mask)) {
let cost: u32 = (0..m)
.filter(|i| mask & (1 << i) != 0)
.map(|i| edges[i].cost())
.sum();
feasible.push((mask, cost, order));
}
}
let minimal: Vec<(u64, u32, Vec<String>)> = feasible
.iter()
.filter(|(mask, _, _)| {
!feasible
.iter()
.any(|(other, _, _)| other != mask && (mask & other) == *other)
})
.cloned()
.collect();
let mut sols: Vec<CutSolution> = minimal
.into_iter()
.map(|(mask, cost, proof_order)| {
let mut removed: Vec<&ClassifiedEdge> =
(0..m).filter(|i| mask & (1 << i) != 0).map(|i| &edges[i]).collect();
removed.sort_by_key(|e| (e.cost(), e.from.clone(), e.to.clone()));
debug_assert_eq!(proof_order.len(), nodes.len(), "proof must cover every node");
CutSolution {
edges: removed.iter().map(|e| (e.from.clone(), e.to.clone())).collect(),
cost,
actions: removed.iter().map(|e| e.action()).collect(),
proof_order,
}
})
.collect();
sols.sort_by(|a, b| {
(a.cost, a.edges.len(), &a.edges).cmp(&(b.cost, b.edges.len(), &b.edges))
});
sols
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SymptomKind {
VersionGap,
NonMemberDep,
Skew,
Dirty,
CheapCycle,
HardCycle,
PromoteBlock,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Symptoms {
counts: BTreeMap<SymptomKind, usize>,
}
impl Symptoms {
pub fn new() -> Self {
Self::default()
}
pub fn set(&mut self, kind: SymptomKind, n: usize) {
if n == 0 {
self.counts.remove(&kind);
} else {
self.counts.insert(kind, n);
}
}
pub fn count(&self, kind: SymptomKind) -> usize {
self.counts.get(&kind).copied().unwrap_or(0)
}
pub fn total(&self) -> usize {
self.counts.values().sum()
}
pub fn is_green(&self) -> bool {
self.counts.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemedyClass {
Auto,
Decision,
}
#[derive(Debug, Clone)]
pub struct RemedyOp {
pub id: &'static str,
pub class: RemedyClass,
pub target: SymptomKind,
pub action: &'static str,
}
pub fn remedy_table() -> Vec<RemedyOp> {
use RemedyClass::*;
use SymptomKind::*;
vec![
RemedyOp {
id: "fix-versions",
class: Auto,
target: VersionGap,
action: "run --fix: fill `version = \"…\"` on every member path dep",
},
RemedyOp {
id: "tidy-dirty",
class: Auto,
target: Dirty,
action: "--tidy: drop working-tree noise (.claude/, *.arrows) and commit",
},
RemedyOp {
id: "bump-skew-join",
class: Auto,
target: Skew,
action: "bump each behind repo to the semver-join (⊔) target",
},
RemedyOp {
id: "cut-cycle-cheap",
class: Auto,
target: CheapCycle,
action: "MFAS: cut the cost-≤1 edge (drop a dev edge / gate a feature off)",
},
RemedyOp {
id: "add-member",
class: Decision,
target: NonMemberDep,
action: "DECIDE: add the non-member dep to the release cascade, or publish it first",
},
RemedyOp {
id: "split-crate",
class: Decision,
target: HardCycle,
action: "DECIDE: split a crate (extract a leaf) to break the cycle — MFAS crate-split",
},
RemedyOp {
id: "unfork",
class: Decision,
target: PromoteBlock,
action: "DECIDE: publish the forked dep's real version upstream, or wait for it",
},
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanStep {
pub op_id: &'static str,
pub class: RemedyClass,
pub action: &'static str,
pub closes: SymptomKind,
pub count: usize,
}
#[derive(Debug, Clone)]
pub struct RemedyPlan {
pub steps: Vec<PlanStep>,
pub decisions: Vec<PlanStep>,
pub green: bool,
}
pub fn plan(initial: &Symptoms, table: &[RemedyOp]) -> RemedyPlan {
let mut state = initial.clone();
let mut steps = Vec::new();
let budget = initial.total() + 1; let mut guard = 0;
loop {
guard += 1;
assert!(guard <= budget + 1, "planner must converge (strict-decrease invariant)");
let Some(op) = table
.iter()
.find(|o| o.class == RemedyClass::Auto && state.count(o.target) > 0)
else {
break;
};
let before = state.total();
let closed = state.count(op.target);
state.set(op.target, 0); debug_assert!(state.total() < before, "AUTO op must strictly reduce the multiset");
steps.push(PlanStep {
op_id: op.id,
class: RemedyClass::Auto,
action: op.action,
closes: op.target,
count: closed,
});
}
let decisions: Vec<PlanStep> = table
.iter()
.filter(|o| o.class == RemedyClass::Decision && state.count(o.target) > 0)
.map(|o| PlanStep {
op_id: o.id,
class: RemedyClass::Decision,
action: o.action,
closes: o.target,
count: state.count(o.target),
})
.collect();
let green = state.is_green();
RemedyPlan { steps, decisions, green }
}
#[cfg(test)]
mod tests {
use super::*;
fn adj(pairs: &[(&str, &[&str])]) -> Adj {
pairs
.iter()
.map(|(n, ds)| (n.to_string(), ds.iter().map(|s| s.to_string()).collect()))
.collect()
}
fn edge(from: &str, to: &str, class: EdgeClass) -> ClassifiedEdge {
ClassifiedEdge { from: from.to_string(), to: to.to_string(), via: vec![to.to_string()], class }
}
#[test]
fn tarjan_finds_the_scc() {
let g = adj(&[("a", &["b"]), ("b", &["c"]), ("c", &["a", "d"]), ("d", &[])]);
let cs = cycles(&g);
assert_eq!(cs.len(), 1, "exactly one cycle");
assert_eq!(cs[0], vec!["a", "b", "c"], "the SCC = the 3-node loop, sorted");
assert_eq!(tarjan_scc(&g).len(), 2, "{{a,b,c}} + {{d}}");
nornir_testmatrix::functional_status(
"release-graph-math",
"tarjan_scc",
cs.len() == 1 && cs[0] == vec!["a", "b", "c"],
"3-node cycle a→b→c→a isolated from tail d",
);
}
#[test]
fn dag_has_no_cycle_and_topo_orders() {
let g = adj(&[("app", &["lib"]), ("lib", &["core"]), ("core", &[])]);
assert!(cycles(&g).is_empty(), "a DAG has no cycle");
let cond = condense(&g);
let order: Vec<String> = cond
.topo_order()
.iter()
.map(|&c| cond.comps[c].join("+"))
.collect();
assert_eq!(order, vec!["core", "lib", "app"]);
nornir_testmatrix::functional_status(
"release-graph-math",
"condensation_topo",
order == vec!["core", "lib", "app"],
"deps-first coarse order over a DAG",
);
}
#[test]
fn waves_are_antichains() {
let g = adj(&[
("core", &[]),
("a", &["core"]),
("b", &["core"]),
("app", &["a", "b"]),
]);
let waves = condense(&g).waves();
assert_eq!(waves.len(), 3, "three waves");
assert_eq!(waves[0], vec!["core"], "wave 0 = the shared leaf");
assert_eq!(waves[1], vec!["a", "b"], "wave 1 = the two independents, concurrent");
assert_eq!(waves[2], vec!["app"], "wave 2 = the sink");
nornir_testmatrix::functional_status(
"release-graph-math",
"publish_waves",
waves.len() == 3 && waves[1] == vec!["a", "b"],
"antichain wave 1 = {a,b} publishable concurrently",
);
}
#[test]
fn mfas_ranks_cheapest_cut_first() {
let nodes = vec!["a".to_string(), "b".to_string()];
let edges = vec![
edge("a", "b", EdgeClass::Normal), edge("b", "a", EdgeClass::Optional), ];
let sols = min_feedback_arc_set(&nodes, &edges);
assert!(!sols.is_empty(), "the cycle is breakable");
let best = &sols[0];
assert_eq!(best.edges, vec![("b".to_string(), "a".to_string())], "cut the optional edge");
assert_eq!(best.cost, 1, "gate-off costs 1");
assert_eq!(best.proof_order.len(), 2, "residual is a DAG (proof covers both nodes)");
for s in &sols {
assert_eq!(s.proof_order.len(), nodes.len(), "each cut proves a DAG");
}
assert!(sols.windows(2).all(|w| w[0].cost <= w[1].cost), "ranked by cost");
nornir_testmatrix::functional_status(
"release-graph-math",
"mfas_ranking",
best.cost == 1 && best.edges == vec![("b".to_string(), "a".to_string())],
"optional-edge cut (cost 1) ranked above crate-split (cost ≥10)",
);
}
#[test]
fn mfas_prefers_free_dev_cut() {
let nodes = vec!["x".to_string(), "y".to_string()];
let edges = vec![
edge("x", "y", EdgeClass::Dev), edge("y", "x", EdgeClass::Normal), ];
let sols = min_feedback_arc_set(&nodes, &edges);
assert_eq!(sols[0].cost, 0, "the dev cut is free");
assert_eq!(sols[0].edges, vec![("x".to_string(), "y".to_string())]);
}
#[test]
fn planner_converges_autos_then_asks() {
let mut s = Symptoms::new();
s.set(SymptomKind::VersionGap, 37);
s.set(SymptomKind::Skew, 2);
s.set(SymptomKind::NonMemberDep, 4);
s.set(SymptomKind::HardCycle, 1);
let plan = plan(&s, &remedy_table());
let auto_ids: Vec<&str> = plan.steps.iter().map(|p| p.op_id).collect();
assert!(auto_ids.contains(&"fix-versions"), "version gaps auto-fixed first");
assert!(auto_ids.contains(&"bump-skew-join"), "skew auto-bumped");
assert_eq!(plan.steps.len(), 2, "exactly the two applicable AUTO ops");
assert_eq!(plan.steps[0].op_id, "fix-versions", "--fix is the first operator");
let dec_ids: Vec<&str> = plan.decisions.iter().map(|p| p.op_id).collect();
assert_eq!(dec_ids, vec!["add-member", "split-crate"], "two crisp asks, ordered");
assert!(!plan.green, "not green: decisions block the auto-heal");
nornir_testmatrix::functional_status(
"release-graph-math",
"planner_fixpoint",
plan.steps.len() == 2 && plan.decisions.len() == 2 && !plan.green,
"37 version-gaps + 2 skew auto-closed; non-member + hard-cycle → 2 asks",
);
}
#[test]
fn planner_green_on_clean_state() {
let plan = plan(&Symptoms::new(), &remedy_table());
assert!(plan.steps.is_empty() && plan.decisions.is_empty() && plan.green);
}
#[test]
fn planner_auto_cuts_cheap_cycle() {
let mut s = Symptoms::new();
s.set(SymptomKind::CheapCycle, 1);
let plan = plan(&s, &remedy_table());
assert_eq!(plan.steps.len(), 1);
assert_eq!(plan.steps[0].op_id, "cut-cycle-cheap");
assert!(plan.green, "the cheap cut heals it fully");
}
fn is_valid_topo(
nodes: &[&str],
all_edges: &[(&str, &str)],
removed: &[(String, String)],
order: &[String],
) -> bool {
let want: BTreeSet<&str> = nodes.iter().copied().collect();
let got: BTreeSet<&str> = order.iter().map(|s| s.as_str()).collect();
if want != got || order.len() != nodes.len() {
return false;
}
let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
let cut: BTreeSet<(String, String)> = removed.iter().cloned().collect();
all_edges.iter().all(|&(from, to)| {
if cut.contains(&(from.to_string(), to.to_string())) {
return true; }
pos(to) < pos(from)
})
}
#[test]
fn disconnected_two_cycles_and_isolated_node() {
let g = adj(&[
("a", &["b"]), ("b", &["a"]), ("c", &["d"]), ("d", &["c"]), ("e", &[]), ]);
let cs = cycles(&g);
assert_eq!(cs.len(), 2, "two independent cycles");
assert!(cs.contains(&vec!["a".to_string(), "b".to_string()]));
assert!(cs.contains(&vec!["c".to_string(), "d".to_string()]));
assert!(!cs.iter().any(|c| c.contains(&"e".to_string())), "e is no cycle");
let cond = condense(&g);
assert_eq!(cond.comps.len(), 3);
assert_eq!(cond.topo_order().len(), 3, "condensation is a DAG (all ordered)");
let waves = cond.waves();
assert_eq!(waves.len(), 1, "no cross-component edges ⇒ a single wave");
assert_eq!(waves[0], vec!["a", "b", "c", "d", "e"], "all publishable together");
assert_eq!(cond.must_break().len(), 2);
nornir_testmatrix::functional_status(
"release-graph-math",
"disconnected_components",
cs.len() == 2 && waves.len() == 1,
"two disjoint 2-cycles + isolated node isolated correctly",
);
}
#[test]
fn self_loop_is_a_one_node_cycle() {
let g = adj(&[("n", &["n"]), ("m", &["n"])]);
assert!(is_self_loop(&g, &["n".to_string()]), "n→n is a self-loop");
assert!(!is_self_loop(&g, &["m".to_string()]), "m has no self-edge");
let cs = cycles(&g);
assert_eq!(cs, vec![vec!["n".to_string()]], "only the self-loop is a cycle");
assert_eq!(tarjan_scc(&g).len(), 2);
nornir_testmatrix::functional_status(
"release-graph-math",
"self_loop_cycle",
cs == vec![vec!["n".to_string()]],
"self-loop n→n flagged as a size-1 cycle; clean singleton m is not",
);
}
#[test]
fn condensation_groups_scc_and_stays_acyclic() {
let g = adj(&[
("a", &["b"]), ("b", &["c"]), ("c", &["a", "d"]),
("d", &["e"]), ("e", &[]),
]);
let cond = condense(&g);
let ca = cond.comp_of["a"];
assert_eq!(cond.comp_of["b"], ca);
assert_eq!(cond.comp_of["c"], ca);
assert_ne!(cond.comp_of["d"], ca);
assert_ne!(cond.comp_of["e"], cond.comp_of["d"]);
assert_eq!(cond.must_break(), vec![["a".to_string(), "b".to_string(), "c".to_string()].as_slice()]);
let order: Vec<String> =
cond.topo_order().iter().map(|&c| cond.comps[c].join("+")).collect();
assert_eq!(order, vec!["e", "d", "a+b+c"], "tail leaf first, cycle super-node last");
assert!(cond.dag.iter().enumerate().all(|(i, deps)| !deps.contains(&i)));
nornir_testmatrix::functional_status(
"release-graph-math",
"condensation_acyclic",
order == vec!["e", "d", "a+b+c"],
"3-cycle collapsed to one super-node; condensation topo-sorts deps-first",
);
}
#[test]
fn mfas_three_cycle_yields_three_minimal_single_cuts() {
let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let all: [(&str, &str); 3] = [("a", "b"), ("b", "c"), ("c", "a")];
let edges = vec![
edge("a", "b", EdgeClass::Normal),
edge("b", "c", EdgeClass::Normal),
edge("c", "a", EdgeClass::Normal),
];
let sols = min_feedback_arc_set(&nodes, &edges);
assert_eq!(sols.len(), 3, "three minimal single-edge cuts, no supersets");
for s in &sols {
assert_eq!(s.edges.len(), 1, "each minimal cut removes exactly one edge");
assert_eq!(s.cost, 11, "Normal cut = 10 + via_len(1)");
assert!(
is_valid_topo(&["a", "b", "c"], &all, &s.edges, &s.proof_order),
"proof_order must be a valid residual DAG order: {:?}",
s.proof_order
);
}
let mut cut_edges: Vec<(String, String)> =
sols.iter().map(|s| s.edges[0].clone()).collect();
cut_edges.sort();
assert_eq!(
cut_edges,
vec![
("a".to_string(), "b".to_string()),
("b".to_string(), "c".to_string()),
("c".to_string(), "a".to_string()),
]
);
nornir_testmatrix::functional_status(
"release-graph-math",
"mfas_three_cycle_minimal",
sols.len() == 3 && sols.iter().all(|s| s.edges.len() == 1),
"3-cycle: 3 minimal single-edge cuts, each a proven residual DAG",
);
}
#[test]
fn mfas_drops_non_minimal_superset_cuts() {
let nodes = vec!["a".to_string(), "b".to_string()];
let edges = vec![
edge("a", "b", EdgeClass::Normal),
edge("b", "a", EdgeClass::Optional),
];
let sols = min_feedback_arc_set(&nodes, &edges);
assert_eq!(sols.len(), 2, "both single-edge cuts; the 2-edge superset is dropped");
assert!(sols.iter().all(|s| s.edges.len() == 1), "no non-minimal (2-edge) cut kept");
assert_eq!(sols[0].cost, 1);
assert_eq!(sols[0].edges, vec![("b".to_string(), "a".to_string())]);
}
#[test]
fn edge_class_cost_schedule() {
assert_eq!(EdgeClass::Dev.cost(5), 0, "a dev cut is always free");
assert_eq!(EdgeClass::Optional.cost(3), 1, "an optional gate-off is flat 1");
assert_eq!(EdgeClass::Normal.cost(0), 10, "base crate-split cost");
assert_eq!(EdgeClass::Normal.cost(2), 12, "cost grows with via_len");
assert!(EdgeClass::Dev.cost(9) < EdgeClass::Optional.cost(9));
assert!(EdgeClass::Optional.cost(9) < EdgeClass::Normal.cost(0));
}
#[test]
fn planner_surfaces_decision_only_symptom() {
let mut s = Symptoms::new();
s.set(SymptomKind::PromoteBlock, 3);
let plan = plan(&s, &remedy_table());
assert!(plan.steps.is_empty(), "nothing auto-heals a promote block");
assert_eq!(plan.decisions.len(), 1);
assert_eq!(plan.decisions[0].op_id, "unfork");
assert_eq!(plan.decisions[0].count, 3, "the ask carries the finding count");
assert!(!plan.green);
}
#[test]
fn planner_multiple_autos_reach_green() {
let mut s = Symptoms::new();
s.set(SymptomKind::Dirty, 1);
s.set(SymptomKind::CheapCycle, 2);
assert_eq!(s.total(), 3);
s.set(SymptomKind::Dirty, 0); assert_eq!(s.total(), 2);
assert_eq!(s.count(SymptomKind::Dirty), 0);
s.set(SymptomKind::Dirty, 1); let plan = plan(&s, &remedy_table());
let ids: BTreeSet<&str> = plan.steps.iter().map(|p| p.op_id).collect();
assert!(ids.contains("tidy-dirty") && ids.contains("cut-cycle-cheap"));
assert_eq!(plan.steps.len(), 2);
assert!(plan.green && plan.decisions.is_empty(), "all AUTO ⇒ self-healed green");
}
}