use std::collections::{BTreeMap, BTreeSet};
use crate::backlog_order::OverrideReason;
use crate::relation_graph::EntityKey;
use super::channels;
use super::config::PriorityConfig;
use super::graph::PriorityGraph;
use super::order;
use super::partition::{StatusClass, status_class};
use super::view::ReasonKind;
pub(crate) const FORK_MIN_ARMS: usize = 2;
pub(crate) const JOIN_MIN_PREREQS: usize = 2;
pub(crate) const GATING_MIN_BLOCKS: usize = 2;
pub(crate) const PLATEAU_EPS: f64 = 0.01;
pub(crate) const INVERSION_MIN_GAP: f64 = 0.5;
pub(crate) const DISPLACEMENT_MIN_DELTA: usize = 3;
pub(crate) const BETA_LO: f64 = 0.0;
pub(crate) const BETA_HI: f64 = 1.0;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Finding {
Fork { hub: String, arms: Vec<String> },
Join { node: String, prereqs: Vec<String> },
GatingFanOut { record: String, blocks: Vec<String> },
ValueInversion {
blocker: String,
blocked: String,
gap: f64,
},
Displacement {
node: String,
score_rank: usize,
constrained_rank: usize,
delta: usize,
},
Plateau { members: Vec<String>, span: f64 },
OrderInstability {
high: String,
low: String,
moved: usize,
},
ArmResequencing {
hub: String,
order_lo: Vec<String>,
order_hi: Vec<String>,
moved: usize,
},
Provenance(ReasonKind),
}
fn count_magnitude(n: usize) -> f64 {
f64::from(u32::try_from(n).unwrap_or(u32::MAX))
}
impl Finding {
pub(crate) fn magnitude(&self) -> f64 {
match self {
Finding::Fork { arms, .. } => count_magnitude(arms.len()),
Finding::Join { prereqs, .. } => count_magnitude(prereqs.len()),
Finding::GatingFanOut { blocks, .. } => count_magnitude(blocks.len()),
Finding::ValueInversion { gap, .. } => *gap,
Finding::Displacement { delta, .. } => count_magnitude(*delta),
Finding::Plateau { members, .. } => count_magnitude(members.len()),
Finding::OrderInstability { moved, .. } | Finding::ArmResequencing { moved, .. } => {
count_magnitude(*moved)
}
Finding::Provenance(ReasonKind::CycleDegraded { nodes }) => {
count_magnitude(nodes.len())
}
Finding::Provenance(_) => 2.0,
}
}
pub(crate) fn kind_label(&self) -> &'static str {
match self {
Finding::Fork { .. } => "forks",
Finding::Join { .. } => "joins",
Finding::GatingFanOut { .. } => "gating fan-out",
Finding::ValueInversion { .. } => "value inversions",
Finding::Displacement { .. } => "displacements",
Finding::Plateau { .. } => "plateaus",
Finding::OrderInstability { .. } => "order instability",
Finding::ArmResequencing { .. } => "arm resequencing",
Finding::Provenance(_) => "provenance",
}
}
}
pub(crate) struct BetaEndpoints {
pub(crate) lo: PriorityGraph,
pub(crate) hi: PriorityGraph,
}
fn class_of(g: &PriorityGraph, key: EntityKey) -> StatusClass {
match g.attrs.get(&key) {
Some(a) => status_class(a.kind, a.status.as_deref()),
None => StatusClass::Unrecognised,
}
}
fn refs(keys: &[EntityKey]) -> Vec<String> {
keys.iter().map(|k| k.canonical()).collect()
}
fn fork_arms(g: &PriorityGraph) -> Vec<(EntityKey, Vec<EntityKey>)> {
let mut out = Vec::new();
for &hub in g.attrs.keys() {
if class_of(g, hub) == StatusClass::Gating {
continue; }
let arms: Vec<EntityKey> = channels::blocking(g, hub)
.into_iter()
.filter(|a| class_of(g, *a) != StatusClass::Terminal)
.collect();
if arms.len() >= FORK_MIN_ARMS {
out.push((hub, arms));
}
}
out
}
fn forks(g: &PriorityGraph) -> Vec<Finding> {
fork_arms(g)
.into_iter()
.map(|(hub, arms)| Finding::Fork {
hub: hub.canonical(),
arms: refs(&arms),
})
.collect()
}
fn joins(g: &PriorityGraph) -> Vec<Finding> {
let mut out = Vec::new();
for &node in g.attrs.keys() {
let prereqs = channels::blocked_by(g, node);
if prereqs.len() >= JOIN_MIN_PREREQS {
out.push(Finding::Join {
node: node.canonical(),
prereqs: refs(&prereqs),
});
}
}
out
}
fn gating_fan_out(g: &PriorityGraph) -> Vec<Finding> {
let mut out = Vec::new();
for &hub in g.attrs.keys() {
if class_of(g, hub) != StatusClass::Gating {
continue;
}
let blocks: Vec<EntityKey> = channels::blocking(g, hub)
.into_iter()
.filter(|b| class_of(g, *b) != StatusClass::Terminal)
.collect();
if blocks.len() >= GATING_MIN_BLOCKS {
out.push(Finding::GatingFanOut {
record: hub.canonical(),
blocks: refs(&blocks),
});
}
}
out
}
fn value_inversions(g: &PriorityGraph) -> Vec<Finding> {
let mut out = Vec::new();
for &blocked in g.attrs.keys() {
let blocked_base = channels::base(g, blocked);
for blocker in channels::blocked_by(g, blocked) {
let gap = blocked_base - channels::base(g, blocker);
if gap > INVERSION_MIN_GAP {
out.push(Finding::ValueInversion {
blocker: blocker.canonical(),
blocked: blocked.canonical(),
gap,
});
}
}
}
out
}
fn displacements(g: &PriorityGraph) -> Vec<Finding> {
let nodes: Vec<EntityKey> = g
.attrs
.keys()
.copied()
.filter(|&k| channels::eligible(g, k) && !channels::promoted(g, k))
.collect();
let mut constrained = nodes.clone();
constrained.sort_by(|a, b| {
let ra = u8::from(channels::blocked(g, *a));
let rb = u8::from(channels::blocked(g, *b));
ra.cmp(&rb)
.then(channels::score(g, *b).total_cmp(&channels::score(g, *a)))
.then(a.cmp(b))
});
let mut by_score = nodes.clone();
by_score.sort_by(|a, b| {
channels::score(g, *b)
.total_cmp(&channels::score(g, *a))
.then(a.cmp(b))
});
let cpos: BTreeMap<EntityKey, usize> = constrained
.iter()
.enumerate()
.map(|(i, &k)| (k, i))
.collect();
let spos: BTreeMap<EntityKey, usize> =
by_score.iter().enumerate().map(|(i, &k)| (k, i)).collect();
let mut out = Vec::new();
for &k in &nodes {
let (Some(&c), Some(&s)) = (cpos.get(&k), spos.get(&k)) else {
continue;
};
let delta = c.abs_diff(s);
if delta >= DISPLACEMENT_MIN_DELTA {
out.push(Finding::Displacement {
node: k.canonical(),
score_rank: s,
constrained_rank: c,
delta,
});
}
}
out
}
fn frontier_order_of(g: &PriorityGraph) -> Vec<EntityKey> {
let actionable_set: BTreeSet<EntityKey> = g
.attrs
.keys()
.copied()
.filter(|&k| channels::actionable(g, k) && !channels::promoted(g, k))
.collect();
let actionable: Vec<EntityKey> = actionable_set.iter().copied().collect();
let preds = order::surviving_seq_predecessors(g, &actionable_set);
order::frontier_order(&actionable, &|k| channels::score(g, k), &preds)
}
fn plateaus(g: &PriorityGraph) -> Vec<Finding> {
let ordering = frontier_order_of(g);
let scored: Vec<(EntityKey, f64)> = ordering
.iter()
.map(|&k| (k, channels::score(g, k)))
.collect();
let mut out = Vec::new();
let mut run: Vec<(EntityKey, f64)> = Vec::new();
for &(k, s) in &scored {
match run.last() {
Some(&(_, prev)) if (prev - s).abs() < PLATEAU_EPS => run.push((k, s)),
_ => {
flush_plateau(&mut out, &run);
run.clear();
run.push((k, s));
}
}
}
flush_plateau(&mut out, &run);
out
}
fn flush_plateau(out: &mut Vec<Finding>, run: &[(EntityKey, f64)]) {
if run.get(1).is_none() {
return;
}
let members: Vec<EntityKey> = run.iter().map(|&(k, _)| k).collect();
let first = run.first().map_or(0.0, |&(_, s)| s);
let last = run.last().map_or(0.0, |&(_, s)| s);
out.push(Finding::Plateau {
members: refs(&members),
span: (first - last).abs(),
});
}
fn provenance(g: &PriorityGraph) -> Vec<Finding> {
let mut out = Vec::new();
let mut edges: BTreeMap<(EntityKey, EntityKey), OverrideReason> = BTreeMap::new();
for &k in g.attrs.keys() {
for (from, to, reason) in channels::evicted_seq_edges(g, k) {
edges.entry((from, to)).or_insert(reason);
}
}
for ((from, to), reason) in edges {
out.push(Finding::Provenance(ReasonKind::EvictedEdge {
from: from.canonical(),
to: to.canonical(),
reason,
}));
}
for component in channels::dep_cycles(g) {
let nodes: Vec<String> = component.into_iter().map(EntityKey::canonical).collect();
out.push(Finding::Provenance(ReasonKind::CycleDegraded { nodes }));
}
out
}
fn positions(order: &[EntityKey]) -> BTreeMap<EntityKey, usize> {
order.iter().enumerate().map(|(i, &k)| (k, i)).collect()
}
fn order_instability(base: &PriorityGraph, betas: &BetaEndpoints) -> Vec<Finding> {
let base_order = frontier_order_of(base);
let lo = positions(&frontier_order_of(&betas.lo));
let hi = positions(&frontier_order_of(&betas.hi));
let mut out = Vec::new();
for pair in base_order.windows(2) {
let [a, b] = *pair else { continue };
let (Some(&la), Some(&lb), Some(&ha), Some(&hb)) =
(lo.get(&a), lo.get(&b), hi.get(&a), hi.get(&b))
else {
continue;
};
if (la < lb) != (ha < hb) {
let moved = la.abs_diff(ha).max(lb.abs_diff(hb));
out.push(Finding::OrderInstability {
high: a.canonical(),
low: b.canonical(),
moved,
});
}
}
out
}
fn arm_order(g: &PriorityGraph, arms: &[EntityKey]) -> Vec<EntityKey> {
let set: BTreeSet<EntityKey> = arms.iter().copied().collect();
let preds = order::surviving_seq_predecessors(g, &set);
order::frontier_order(arms, &|k| channels::score(g, k), &preds)
}
fn arm_resequencing(base: &PriorityGraph, betas: &BetaEndpoints) -> Vec<Finding> {
let mut out = Vec::new();
for (hub, arms) in fork_arms(base) {
let lo = arm_order(&betas.lo, &arms);
let hi = arm_order(&betas.hi, &arms);
if lo != hi {
let moved = lo.iter().zip(hi.iter()).filter(|(a, b)| a != b).count();
out.push(Finding::ArmResequencing {
hub: hub.canonical(),
order_lo: refs(&lo),
order_hi: refs(&hi),
moved,
});
}
}
out
}
pub(crate) fn detect(
base: &PriorityGraph,
_cfg: &PriorityConfig,
betas: Option<&BetaEndpoints>,
) -> Vec<Finding> {
let mut findings = Vec::new();
findings.extend(forks(base));
findings.extend(joins(base));
findings.extend(gating_fan_out(base));
findings.extend(value_inversions(base));
findings.extend(displacements(base));
findings.extend(plateaus(base));
findings.extend(provenance(base));
if let Some(betas) = betas {
findings.extend(order_instability(base, betas));
findings.extend(arm_resequencing(base, betas));
}
findings.sort_by(|a, b| {
a.kind_label()
.cmp(b.kind_label())
.then(b.magnitude().total_cmp(&a.magnitude()))
});
findings
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use crate::priority::config;
use crate::priority::graph;
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn write(root: &Path, rel: &str, body: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
fn seed_issue(root: &Path, id: u32, status: &str, rels: &str) {
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"i\"\ntitle = \"I{id}\"\nkind = \"issue\"\nstatus = \"{status}\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\n{rels}"
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
fn seed_valued(root: &Path, id: u32, value: f64, rels: &str) {
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"i\"\ntitle = \"I{id}\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[estimate]\nlower = 0.0\nupper = 10.0\n[value]\nvalue = {value}\n\
[relationships]\n{rels}"
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
fn seed_question(root: &Path, id: u32, status: &str) {
use crate::test_support::SCHEMA_KNOWLEDGE;
write(
root,
&format!(".doctrine/knowledge/question/{id:03}/record-{id:03}.toml"),
&format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\nid = {id}\nslug = \"q{id}\"\n\
title = \"Q{id}\"\nrecord_kind = \"question\"\nstatus = \"{status}\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n"
),
);
write(
root,
&format!(".doctrine/knowledge/question/{id:03}/record-{id:03}.md"),
"q\n",
);
}
fn detect_root(root: &Path) -> Vec<Finding> {
let g = graph::build(root).unwrap();
detect(&g, &config::load(root), None)
}
fn seed_interval(root: &Path, id: u32, value: f64, lo: f64, hi: f64, rels: &str) {
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"i\"\ntitle = \"I{id}\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[estimate]\nlower = {lo}\nupper = {hi}\n[value]\nvalue = {value}\n\
[relationships]\n{rels}"
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
fn detect_beta(root: &Path) -> Vec<Finding> {
use crate::catalog::scan::ScanMode;
let scanned =
crate::relation_graph::scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
let cfg = config::load(root);
let base = graph::build_from_with_cfg(&scanned, root, &cfg).unwrap();
let mut lo_cfg = cfg.clone();
lo_cfg.estimate.skew = BETA_LO;
let mut hi_cfg = cfg.clone();
hi_cfg.estimate.skew = BETA_HI;
let lo = graph::build_from_with_cfg(&scanned, root, &lo_cfg).unwrap();
let hi = graph::build_from_with_cfg(&scanned, root, &hi_cfg).unwrap();
let betas = BetaEndpoints { lo, hi };
detect(&base, &cfg, Some(&betas))
}
fn has_fork(fs: &[Finding], hub: &str) -> bool {
fs.iter()
.any(|f| matches!(f, Finding::Fork { hub: h, .. } if h == hub))
}
fn has_join(fs: &[Finding], node: &str) -> bool {
fs.iter()
.any(|f| matches!(f, Finding::Join { node: n, .. } if n == node))
}
fn has_gating(fs: &[Finding], record: &str) -> bool {
fs.iter()
.any(|f| matches!(f, Finding::GatingFanOut { record: r, .. } if r == record))
}
#[test]
fn fork_fires_on_two_nonterminal_arms_and_is_silent_on_one() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "");
seed_issue(root, 2, "open", "needs = [\"ISS-001\"]\n");
seed_issue(root, 3, "open", "needs = [\"ISS-001\"]\n");
seed_issue(root, 10, "open", "");
seed_issue(root, 11, "open", "needs = [\"ISS-010\"]\n");
let fs = detect_root(root);
assert!(has_fork(&fs, "ISS-001"), "ISS-001 forks 2 arms: {fs:?}");
assert!(
!has_fork(&fs, "ISS-010"),
"single dependent is not a fork: {fs:?}"
);
let fork = fs
.iter()
.find(|f| matches!(f, Finding::Fork { hub, .. } if hub == "ISS-001"))
.unwrap();
if let Finding::Fork { arms, .. } = fork {
assert_eq!(arms, &vec!["ISS-002".to_string(), "ISS-003".to_string()]);
}
}
#[test]
fn fork_filters_terminal_arms() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "");
seed_issue(root, 2, "open", "needs = [\"ISS-001\"]\n");
seed_issue(root, 3, "closed", "needs = [\"ISS-001\"]\n");
let fs = detect_root(root);
assert!(
!has_fork(&fs, "ISS-001"),
"a terminal arm does not count toward the fork: {fs:?}"
);
}
#[test]
fn join_fires_on_two_prereqs_and_is_silent_on_one() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "needs = [\"ISS-002\", \"ISS-003\"]\n");
seed_issue(root, 2, "open", "");
seed_issue(root, 3, "open", "");
seed_issue(root, 10, "open", "needs = [\"ISS-011\"]\n");
seed_issue(root, 11, "open", "");
let fs = detect_root(root);
assert!(has_join(&fs, "ISS-001"), "ISS-001 joins 2 prereqs: {fs:?}");
assert!(
!has_join(&fs, "ISS-010"),
"single prereq is not a join: {fs:?}"
);
}
#[test]
fn gating_fan_out_fires_on_gating_hub_and_excludes_fork_double_report() {
let dir = tmp();
let root = dir.path();
seed_question(root, 1, "open");
seed_issue(root, 2, "open", "needs = [\"QUE-001\"]\n");
seed_issue(root, 3, "open", "needs = [\"QUE-001\"]\n");
let fs = detect_root(root);
assert!(has_gating(&fs, "QUE-001"), "gating hub fans out: {fs:?}");
assert!(
!has_fork(&fs, "QUE-001"),
"a gating fan-out is reported ONCE (not a Fork): {fs:?}"
);
}
#[test]
fn gating_fan_out_silent_when_hub_is_workable() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "");
seed_issue(root, 2, "open", "needs = [\"ISS-001\"]\n");
seed_issue(root, 3, "open", "needs = [\"ISS-001\"]\n");
let fs = detect_root(root);
assert!(has_fork(&fs, "ISS-001"));
assert!(
!has_gating(&fs, "ISS-001"),
"a workable hub is never a GatingFanOut: {fs:?}"
);
}
#[test]
fn plateau_fires_on_near_ties_and_is_silent_on_spread() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "");
seed_issue(root, 2, "open", "");
seed_issue(root, 3, "open", "");
let fs = detect_root(root);
let plateau = fs.iter().find(|f| matches!(f, Finding::Plateau { .. }));
assert!(plateau.is_some(), "equal scores form a plateau: {fs:?}");
if let Some(Finding::Plateau { members, .. }) = plateau {
assert_eq!(members.len(), 3, "all three tie: {members:?}");
}
let dir2 = tmp();
let root2 = dir2.path();
seed_valued(root2, 1, 5.0, "");
seed_valued(root2, 2, 50.0, "");
seed_valued(root2, 3, 200.0, "");
let fs2 = detect_root(root2);
assert!(
!fs2.iter().any(|f| matches!(f, Finding::Plateau { .. })),
"well-separated scores form no plateau: {fs2:?}"
);
}
#[test]
fn displacement_fires_when_a_high_score_item_is_blocked() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 100, 500.0, "needs = [\"ISS-200\"]\n"); seed_issue(root, 200, "open", ""); for id in 1..=6 {
seed_valued(root, id, 0.1, ""); }
let fs = detect_root(root);
let displaced = fs
.iter()
.any(|f| matches!(f, Finding::Displacement { node, .. } if node == "ISS-100"));
assert!(
displaced,
"the blocked high-score item is displaced: {fs:?}"
);
let dir2 = tmp();
let root2 = dir2.path();
seed_valued(root2, 1, 5.0, "");
seed_valued(root2, 2, 50.0, "");
seed_valued(root2, 3, 200.0, "");
let fs2 = detect_root(root2);
assert!(
!fs2.iter()
.any(|f| matches!(f, Finding::Displacement { .. })),
"actionable-only, order-agreeing corpus has no displacement: {fs2:?}"
);
}
#[test]
fn value_inversion_fires_on_base_despite_leverage_inflated_score() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 1, 1.0, ""); seed_valued(root, 2, 500.0, "needs = [\"ISS-001\"]\n");
let g = graph::build(root).unwrap();
let fs = detect(&g, &config::load(root), None);
let inversion = fs.iter().any(|f| {
matches!(
f,
Finding::ValueInversion { blocker, blocked, .. }
if blocker == "ISS-001" && blocked == "ISS-002"
)
});
assert!(
inversion,
"low-base blocker of high-base dependent flagged: {fs:?}"
);
let k = |id| EntityKey { prefix: "ISS", id };
assert!(
channels::score(&g, k(1)) > channels::base(&g, k(1)) + 1.0,
"score(blocker) is inflated by the gated dependent's leverage"
);
}
#[test]
fn value_inversion_silent_when_blocker_outweighs_dependent() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 1, 500.0, ""); seed_valued(root, 2, 1.0, "needs = [\"ISS-001\"]\n"); let fs = detect_root(root);
assert!(
!fs.iter()
.any(|f| matches!(f, Finding::ValueInversion { .. })),
"a high-worth blocker gating low-worth work is not an inversion: {fs:?}"
);
}
#[test]
fn one_evicted_seq_edge_yields_exactly_one_provenance_finding() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 1, 10.0, "after = [{ to = \"ISS-002\", rank = 0 }]\n");
seed_valued(root, 2, 100.0, "after = [{ to = \"ISS-001\", rank = 0 }]\n");
let g = graph::build(root).unwrap();
let total_local: usize = g
.attrs
.keys()
.map(|&k| channels::evicted_seq_edges(&g, k).len())
.sum();
assert!(
total_local >= 2,
"both endpoints report the eviction: {total_local}"
);
let fs = detect(&g, &config::load(root), None);
let evicted: Vec<&Finding> = fs
.iter()
.filter(|f| matches!(f, Finding::Provenance(ReasonKind::EvictedEdge { .. })))
.collect();
assert_eq!(
evicted.len(),
1,
"the node-local double-report is globally deduped to ONE finding: {fs:?}"
);
}
#[test]
fn detect_sorts_by_kind_then_magnitude_desc() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "");
seed_issue(root, 2, "open", "needs = [\"ISS-001\"]\n");
seed_issue(root, 3, "open", "needs = [\"ISS-001\"]\n");
seed_issue(root, 4, "open", "needs = [\"ISS-001\"]\n"); seed_issue(root, 10, "open", "");
seed_issue(root, 11, "open", "needs = [\"ISS-010\"]\n");
seed_issue(root, 12, "open", "needs = [\"ISS-010\"]\n");
let fs = detect_root(root);
let fork_hubs: Vec<&String> = fs
.iter()
.filter_map(|f| match f {
Finding::Fork { hub, .. } => Some(hub),
_ => None,
})
.collect();
assert_eq!(
fork_hubs,
vec![&"ISS-001".to_string(), &"ISS-010".to_string()],
"larger fork (3 arms) precedes smaller (2 arms) within the kind: {fs:?}"
);
}
fn order_instability_pairs(fs: &[Finding]) -> Vec<(String, String)> {
fs.iter()
.filter_map(|f| match f {
Finding::OrderInstability { high, low, .. } => Some((high.clone(), low.clone())),
_ => None,
})
.collect()
}
#[test]
fn order_instability_fires_on_endpoint_flip_and_is_silent_when_stable() {
let dir = tmp();
let root = dir.path();
seed_interval(root, 1, 10.0, 1.0, 100.0, "");
seed_interval(root, 2, 5.0, 5.0, 6.0, "");
let fs = detect_beta(root);
let pairs = order_instability_pairs(&fs);
assert_eq!(
pairs.len(),
1,
"exactly one contested adjacent pair: {fs:?}"
);
let members: BTreeSet<&str> = [pairs[0].0.as_str(), pairs[0].1.as_str()]
.into_iter()
.collect();
assert_eq!(
members,
BTreeSet::from(["ISS-001", "ISS-002"]),
"the flipping pair is the two leaves: {fs:?}"
);
let mag = fs
.iter()
.find(|f| matches!(f, Finding::OrderInstability { .. }))
.unwrap()
.magnitude();
assert!(mag >= 1.0, "positions moved ≥ 1: {mag}");
let dir2 = tmp();
let root2 = dir2.path();
seed_interval(root2, 1, 100.0, 1.0, 2.0, "");
seed_interval(root2, 2, 1.0, 1.0, 2.0, "");
let fs2 = detect_beta(root2);
assert!(
!fs2.iter()
.any(|f| matches!(f, Finding::OrderInstability { .. })),
"endpoint-stable order emits no OrderInstability: {fs2:?}"
);
}
#[test]
fn arm_resequencing_fires_when_fork_arm_order_flips_lo_hi() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "");
seed_interval(root, 2, 10.0, 1.0, 100.0, "needs = [\"ISS-001\"]\n");
seed_interval(root, 3, 5.0, 5.0, 6.0, "needs = [\"ISS-001\"]\n");
let fs = detect_beta(root);
let reseq = fs
.iter()
.find(|f| matches!(f, Finding::ArmResequencing { hub, .. } if hub == "ISS-001"));
assert!(reseq.is_some(), "the fork's arms resequence lo↔hi: {fs:?}");
if let Some(Finding::ArmResequencing {
order_lo, order_hi, ..
}) = reseq
{
assert_ne!(order_lo, order_hi, "the two arm orders differ: {fs:?}");
assert_eq!(
order_lo.iter().collect::<BTreeSet<_>>(),
order_hi.iter().collect::<BTreeSet<_>>(),
"same arm set, different order"
);
}
let dir2 = tmp();
let root2 = dir2.path();
seed_issue(root2, 1, "open", "");
seed_interval(root2, 2, 10.0, 1.0, 2.0, "needs = [\"ISS-001\"]\n");
seed_interval(root2, 3, 1.0, 1.0, 2.0, "needs = [\"ISS-001\"]\n");
let fs2 = detect_beta(root2);
assert!(
!fs2.iter()
.any(|f| matches!(f, Finding::ArmResequencing { .. })),
"endpoint-stable arm order emits no ArmResequencing: {fs2:?}"
);
}
#[test]
fn beta_family_silent_without_betas() {
let dir = tmp();
let root = dir.path();
seed_interval(root, 1, 10.0, 1.0, 100.0, "");
seed_interval(root, 2, 5.0, 5.0, 6.0, "");
let fs = detect_root(root); assert!(
!fs.iter().any(|f| matches!(
f,
Finding::OrderInstability { .. } | Finding::ArmResequencing { .. }
)),
"None betas ⇒ β-family silent: {fs:?}"
);
}
}