use std::path::Path;
use crate::catalog::scan::ScanMode;
use crate::relation_graph::{self, EntityKey};
use super::channels;
use super::graph::{self, NodeAttr, PriorityGraph};
use super::order::{frontier_order, surviving_seq_predecessors};
use super::partition::{StatusClass, status_class};
use super::view::{
Actionability, ActionabilityBlock, ActionabilityEdge, ActionabilityNode, ActionabilityView,
BlockersView, Explanation, NextRow, ReasonKind, SurveyRow,
};
fn attr(g: &PriorityGraph, key: EntityKey) -> Option<&NodeAttr> {
g.attrs.get(&key)
}
fn kind_of(g: &PriorityGraph, key: EntityKey) -> String {
attr(g, key).map_or_else(|| key.prefix.to_string(), |a| a.kind.prefix.to_string())
}
fn title_of(g: &PriorityGraph, key: EntityKey) -> String {
attr(g, key).map_or_else(|| key.canonical(), |a| a.title.clone())
}
fn status_of(g: &PriorityGraph, key: EntityKey) -> String {
attr(g, key)
.and_then(|a| a.status.clone())
.unwrap_or_else(|| "—".to_string())
}
fn class_of(g: &PriorityGraph, key: EntityKey) -> StatusClass {
match attr(g, key) {
Some(a) => status_class(a.kind, a.status.as_deref()),
None => StatusClass::Unrecognised,
}
}
fn eligibility_reason(g: &PriorityGraph, key: EntityKey) -> ReasonKind {
ReasonKind::Eligibility {
status: attr(g, key).and_then(|a| a.status.clone()),
class: class_of(g, key),
}
}
fn score_reason(g: &PriorityGraph, key: EntityKey) -> ReasonKind {
ReasonKind::Score {
base: channels::base(g, key),
value_dim: channels::value_dim(g, key),
risk_dim: channels::risk_dim(g, key),
leverage: channels::leverage(g, key),
optionality: channels::optionality(g, key),
total: channels::score(g, key),
}
}
fn refs(keys: &[EntityKey]) -> Vec<String> {
keys.iter().map(|k| k.canonical()).collect()
}
fn actionability(g: &PriorityGraph, key: EntityKey) -> Actionability {
if channels::blocked(g, key) {
Actionability::Blocked
} else {
Actionability::Actionable
}
}
struct SurveyDecorated {
key: EntityKey,
act: Actionability,
score: f64,
blockers: Vec<String>,
}
fn act_rank(a: Actionability) -> u8 {
match a {
Actionability::Actionable => 0,
Actionability::Blocked => 1,
}
}
pub(crate) fn survey_for_map(g: &PriorityGraph, all: bool) -> Vec<SurveyRow> {
let mut rows: Vec<SurveyDecorated> = g
.attrs
.keys()
.copied()
.filter(|&k| {
if all {
return true;
}
channels::eligible(g, k) && !channels::promoted(g, k)
})
.map(|k| SurveyDecorated {
key: k,
act: actionability(g, k),
score: channels::score(g, k),
blockers: refs(&channels::blocked_by(g, k)),
})
.collect();
rows.sort_by(|a, b| {
let act = act_rank(a.act).cmp(&act_rank(b.act));
let score = b.score.total_cmp(&a.score); act.then(score).then_with(|| a.key.cmp(&b.key))
});
rows.into_iter()
.map(|d| {
let mut reasons = vec![eligibility_reason(g, d.key)];
if !d.blockers.is_empty() {
reasons.push(ReasonKind::BlockedBy {
items: d.blockers.clone(),
});
}
reasons.push(score_reason(g, d.key));
SurveyRow {
id: d.key.canonical(),
title: title_of(g, d.key),
kind: kind_of(g, d.key),
status: status_of(g, d.key),
act: d.act,
score: d.score,
blockers: d.blockers,
reasons,
}
})
.collect()
}
pub(crate) fn survey(root: &Path, all: bool, hide_blocked: bool) -> anyhow::Result<Vec<SurveyRow>> {
let g = graph::build(root)?;
let mut rows = survey_for_map(&g, all);
if hide_blocked {
rows.retain(|r| r.act == Actionability::Actionable);
}
Ok(rows)
}
pub(crate) fn survey_view_for_map(g: &PriorityGraph, all: bool) -> ActionabilityView {
use std::collections::{BTreeMap, BTreeSet, VecDeque};
let rows: Vec<_> = survey_for_map(g, all)
.into_iter()
.filter(|r| {
crate::kinds::is_value_bearing(r.kind.as_str())
})
.collect();
let key_by_id: BTreeMap<String, EntityKey> = rows
.iter()
.filter_map(|r| parse_key(&r.id).ok().map(|k| (r.id.clone(), k)))
.collect();
let node_keys: BTreeSet<EntityKey> = key_by_id.values().copied().collect();
let mut blockers_of: BTreeMap<EntityKey, Vec<EntityKey>> = BTreeMap::new();
let mut dependents_of: BTreeMap<EntityKey, Vec<EntityKey>> = BTreeMap::new();
let mut indeg: BTreeMap<EntityKey, usize> = BTreeMap::new();
for &k in &node_keys {
let blockers: Vec<EntityKey> = channels::blocked_by(g, k)
.into_iter()
.filter(|b| node_keys.contains(b))
.collect();
indeg.insert(k, blockers.len());
for &b in &blockers {
dependents_of.entry(b).or_default().push(k);
}
blockers_of.insert(k, blockers);
}
let mut ranks: BTreeMap<EntityKey, u32> = BTreeMap::new();
let mut queue: VecDeque<EntityKey> = indeg
.iter()
.filter(|(_, d)| **d == 0)
.map(|(&k, _)| k)
.collect();
while let Some(k) = queue.pop_front() {
let rank = blockers_of.get(&k).map_or(0, |bs| {
bs.iter()
.filter_map(|b| ranks.get(b))
.max()
.map_or(0, |r| r + 1)
});
ranks.insert(k, rank);
if let Some(deps) = dependents_of.get(&k) {
for &dep in deps {
if let Some(d) = indeg.get_mut(&dep) {
*d -= 1;
if *d == 0 {
queue.push_back(dep);
}
}
}
}
}
for &k in &node_keys {
if !ranks.contains_key(&k) {
let rank = blockers_of.get(&k).map_or(0, |bs| {
bs.iter()
.filter_map(|b| ranks.get(b))
.max()
.map_or(0, |r| r + 1)
});
ranks.insert(k, rank);
}
}
let mut edges: Vec<ActionabilityEdge> = Vec::new();
for &k in &node_keys {
for blocker in &channels::blocked_by(g, k) {
if node_keys.contains(blocker) {
edges.push(ActionabilityEdge {
source: blocker.canonical(),
target: k.canonical(),
kind: "needs".into(),
});
}
}
}
for &k in &node_keys {
if let Some(n) = g.projection.resolve(k) {
for (pred, _) in g.graph.in_edges(g.seq_overlay, n) {
if let Some(pred_key) = g.projection.key_of(pred)
&& node_keys.contains(&pred_key)
{
edges.push(ActionabilityEdge {
source: pred_key.canonical(),
target: k.canonical(),
kind: "after".into(),
});
}
}
}
}
let nodes: Vec<ActionabilityNode> = rows
.into_iter()
.filter_map(|r| {
let k = parse_key(&r.id).ok()?;
let rank = ranks.get(&k).copied().unwrap_or(0);
let actionability = match r.act {
Actionability::Actionable => "actionable",
Actionability::Blocked => "blocked",
};
Some(ActionabilityNode {
id: r.id,
title: r.title,
kind: r.kind,
status: r.status,
actionability: actionability.into(),
score: r.score,
rank,
blockers: r.blockers,
})
})
.collect();
ActionabilityView {
kind: "actionability_graph".into(),
policy_version: "priority.v3".into(),
nodes,
edges,
}
}
pub(crate) fn next(root: &Path) -> anyhow::Result<Vec<NextRow>> {
let g = graph::build(root)?;
let actionable_set: std::collections::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 = surviving_seq_predecessors(&g, &actionable_set);
let order = frontier_order(&actionable, &|k| channels::score(&g, k), &preds);
let rows = order
.into_iter()
.map(|k| {
let blocking = refs(&channels::blocking(&g, k));
let mut reasons = vec![eligibility_reason(&g, k)];
if !blocking.is_empty() {
reasons.push(ReasonKind::Blocking {
items: blocking.clone(),
});
}
reasons.push(score_reason(&g, k));
let (estimate, value, tags) = attr(&g, k).map_or((None, None, Vec::new()), |a| {
(
a.facets.estimate.clone(),
a.facets.value.clone(),
a.facets.tags.clone(),
)
});
NextRow {
id: k.canonical(),
title: title_of(&g, k),
kind: kind_of(&g, k),
status: status_of(&g, k),
act: Actionability::Actionable,
score: channels::score(&g, k),
reasons,
blockers: Vec::new(),
blocking,
estimate,
value,
tags,
}
})
.collect();
Ok(rows)
}
fn parse_key(id: &str) -> anyhow::Result<EntityKey> {
let (kref, qid) = crate::integrity::parse_canonical_ref(id)?;
Ok(EntityKey {
prefix: kref.kind.prefix,
id: qid,
})
}
pub(crate) fn blockers(root: &Path, id: &str, transitive: bool) -> anyhow::Result<BlockersView> {
let key = parse_key(id)?;
let g = graph::build(root)?;
relation_graph::require_minted(&g.projection, key)?;
let (blocked_by, blocking) = if transitive {
(
channels::blocked_by_transitive(&g, key),
channels::blocking_transitive(&g, key),
)
} else {
(channels::blocked_by(&g, key), channels::blocking(&g, key))
};
Ok(BlockersView {
id: key.canonical(),
transitive,
blocked_by: refs(&blocked_by),
blocking: refs(&blocking),
})
}
pub(crate) fn explain(root: &Path, id: &str) -> anyhow::Result<Explanation> {
let key = parse_key(id)?;
let g = graph::build(root)?;
relation_graph::require_minted(&g.projection, key)?;
let eligibility = eligibility_reason(&g, key);
let chain = channels::blocked_by_transitive(&g, key);
let blocker_chain = if chain.is_empty() {
Vec::new()
} else {
vec![ReasonKind::BlockedBy {
items: refs(&chain),
}]
};
let evictions = channels::evicted_seq_edges(&g, key)
.into_iter()
.map(|(from, to, reason)| ReasonKind::EvictedEdge {
from: from.canonical(),
to: to.canonical(),
reason,
})
.collect();
let cycle = channels::dep_cycles(&g)
.into_iter()
.find(|c| c.contains(&key));
let score = score_reason(&g, key);
let mut blocker_chain = blocker_chain;
if let Some(component) = cycle {
let nodes = component.into_iter().map(EntityKey::canonical).collect();
blocker_chain.push(ReasonKind::CycleDegraded { nodes });
}
Ok(Explanation {
id: key.canonical(),
eligibility,
blocker_chain,
evictions,
score,
})
}
pub(crate) fn actionability_block_from(
scanned: &[relation_graph::ScannedEntity],
root: &Path,
id: &str,
) -> anyhow::Result<ActionabilityBlock> {
let key = parse_key(id)?;
let g = graph::build_from(scanned, root)?;
relation_graph::require_minted(&g.projection, key)?;
Ok(ActionabilityBlock {
eligible: channels::eligible(&g, key),
actionable: channels::actionable(&g, key),
blockers: refs(&channels::blocked_by(&g, key)),
blocking: refs(&channels::blocking(&g, key)),
score: channels::score(&g, key),
})
}
pub(crate) fn findings(root: &Path) -> anyhow::Result<Vec<super::findings::Finding>> {
let scanned = relation_graph::scan_entities(root, &mut vec![], ScanMode::default())?;
let cfg = super::config::load(root);
let base = graph::build_from_with_cfg(&scanned, root, &cfg)?;
let betas = beta_endpoints(&scanned, root, &cfg)?;
Ok(super::findings::detect(&base, &cfg, betas.as_ref()))
}
fn has_nonterminal_interval(scanned: &[relation_graph::ScannedEntity]) -> bool {
scanned.iter().any(|e| {
status_class(e.kind, e.status.as_deref()) != StatusClass::Terminal
&& e.estimate.as_ref().is_some_and(|est| est.lower < est.upper)
})
}
pub(crate) fn beta_endpoints(
scanned: &[relation_graph::ScannedEntity],
root: &Path,
cfg: &super::config::PriorityConfig,
) -> anyhow::Result<Option<super::findings::BetaEndpoints>> {
if !has_nonterminal_interval(scanned) {
return Ok(None);
}
let mut lo_cfg = cfg.clone();
lo_cfg.estimate.skew = super::findings::BETA_LO;
let mut hi_cfg = cfg.clone();
hi_cfg.estimate.skew = super::findings::BETA_HI;
let lo = graph::build_from_with_cfg(scanned, root, &lo_cfg)?;
let hi = graph::build_from_with_cfg(scanned, root, &hi_cfg)?;
Ok(Some(super::findings::BetaEndpoints { lo, hi }))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use crate::priority::graph::build;
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 tmp() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn seed_issue(root: &Path, id: u32, status: &str, resolution: &str, axes: &[(&str, &[&str])]) {
let rels = crate::relation::rels_block(&crate::backlog::ISSUE_KIND, axes);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"{status}\"\n\
resolution = \"{resolution}\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
{rels}"
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
#[test]
fn survey_rank_topological_chain_a_to_b_to_c() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-002"])]);
seed_issue(root, 2, "open", "", &[("needs", &["ISS-003"])]);
seed_issue(root, 3, "open", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
let n1 = view.nodes.iter().find(|n| n.id == "ISS-001").unwrap();
let n2 = view.nodes.iter().find(|n| n.id == "ISS-002").unwrap();
let n3 = view.nodes.iter().find(|n| n.id == "ISS-003").unwrap();
assert_eq!(n3.rank, 0, "ISS-003 has no blockers → rank 0");
assert_eq!(n2.rank, 1, "ISS-002 blocked by ISS-003 (rank 0) → rank 1");
assert_eq!(n1.rank, 2, "ISS-001 blocked by ISS-002 (rank 1) → rank 2");
}
#[test]
fn survey_needs_edges_present() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-002"])]);
seed_issue(root, 2, "open", "", &[("needs", &["ISS-003"])]);
seed_issue(root, 3, "open", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert!(
view.edges
.iter()
.any(|e| e.source == "ISS-003" && e.target == "ISS-002" && e.kind == "needs")
);
assert!(
view.edges
.iter()
.any(|e| e.source == "ISS-002" && e.target == "ISS-001" && e.kind == "needs")
);
}
#[test]
fn survey_after_edges_present() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 2, "open", "", &[]);
write(
root,
".doctrine/backlog/issue/001/backlog-001.toml",
"id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nafter = [{ to = \"ISS-002\", rank = 0 }]\n",
);
write(root, ".doctrine/backlog/issue/001/backlog-001.md", "b\n");
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert!(
view.edges
.iter()
.any(|e| e.source == "ISS-002" && e.target == "ISS-001" && e.kind == "after")
);
}
#[test]
fn survey_empty_graph() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "closed", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert!(view.nodes.is_empty());
assert!(view.edges.is_empty());
}
#[test]
fn survey_excludes_terminal() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[]);
seed_issue(root, 2, "closed", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert_eq!(view.nodes.len(), 1, "only the eligible (open) node");
assert_eq!(view.nodes[0].id, "ISS-001");
assert!(view.nodes.iter().all(|n| n.id != "ISS-002"));
}
#[test]
fn survey_terminal_blocker_no_edge() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-002"])]);
seed_issue(root, 2, "closed", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert_eq!(view.nodes.len(), 1);
let n1 = &view.nodes[0];
assert_eq!(n1.id, "ISS-001");
assert!(view.edges.is_empty(), "terminal → eligible edge suppressed");
assert_eq!(n1.actionability, "actionable");
assert_eq!(n1.rank, 0);
}
#[test]
fn survey_for_map_matches_survey_byte_for_byte() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-003"])]);
seed_issue(root, 2, "open", "", &[("needs", &["ISS-003"])]);
seed_issue(root, 3, "open", "", &[]);
let g = build(root).unwrap();
let from_survey = survey(root, false, false).unwrap();
let from_for_map = survey_for_map(&g, false);
assert_eq!(
from_survey, from_for_map,
"survey_for_map must match survey output exactly"
);
}
#[test]
fn actionability_view_set_preserved_after_value_bearing_promotion() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[]);
write(
root,
".doctrine/requirement/005/requirement-005.toml",
"id = 5\nslug = \"r\"\ntitle = \"R\"\nstatus = \"active\"\n",
);
write(root, ".doctrine/requirement/005/requirement-005.md", "r\n");
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert_eq!(view.nodes.len(), 1, "only the ISS appears, not REQ-005");
assert_eq!(view.nodes[0].id, "ISS-001");
let kind_set: std::collections::BTreeSet<&str> =
view.nodes.iter().map(|n| n.kind.as_str()).collect();
let expected: std::collections::BTreeSet<&str> = ["ISS"].iter().copied().collect();
assert_eq!(kind_set, expected, "only work/value-bearing kinds appear");
}
fn seed_valued(root: &Path, id: u32, value: f64, rel_lines: &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{rel_lines}"
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
fn next_ids(root: &Path) -> Vec<String> {
next(root).unwrap().into_iter().map(|r| r.id).collect()
}
fn survey_ids(root: &Path) -> Vec<String> {
survey(root, false, false)
.unwrap()
.into_iter()
.map(|r| r.id)
.collect()
}
#[test]
fn vt5_blocker_of_one_high_value_outranks_blocker_of_five_ideas() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[]); write(
root,
".doctrine/backlog/risk/001/backlog-001.toml",
"id = 1\nslug = \"k\"\ntitle = \"K1\"\nkind = \"risk\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n",
);
write(root, ".doctrine/backlog/risk/001/backlog-001.md", "k\n");
write(
root,
".doctrine/backlog/risk/002/backlog-002.toml",
"id = 2\nslug = \"k\"\ntitle = \"K2\"\nkind = \"risk\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n",
);
write(root, ".doctrine/backlog/risk/002/backlog-002.md", "k\n");
seed_valued(root, 10, 100.0, "needs = [\"RSK-001\"]\n");
for id in 20..25 {
seed_valued(root, id, 0.0, "needs = [\"RSK-002\"]\n");
}
let g = build(root).unwrap();
let rsk1 = EntityKey {
prefix: "RSK",
id: 1,
};
let rsk2 = EntityKey {
prefix: "RSK",
id: 2,
};
assert!(
(channels::score(&g, rsk1) - (50.0 / 6.5 + 1.0 / 11.0)).abs() < 1e-9,
"RSK-001 leverages the one high-value dependent: got {}",
channels::score(&g, rsk1)
);
assert!(
(channels::score(&g, rsk2) - 1.0 / 11.0).abs() < 1e-9,
"RSK-002 gates only zero-value ideas → score = value_dim only: got {}",
channels::score(&g, rsk2)
);
let ids = survey_ids(root);
let p1 = ids.iter().position(|x| x == "RSK-001").unwrap();
let p2 = ids.iter().position(|x| x == "RSK-002").unwrap();
assert!(
p1 < p2,
"RSK-001 outranks RSK-002 by score (not inbound count): {ids:?}"
);
}
#[test]
fn vt5_deep_blocker_of_valuable_cone_outranks_shallow_blocker_of_modest_item() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 1, 0.0, ""); seed_valued(root, 2, 0.0, "needs = [\"ISS-001\"]\n"); seed_valued(root, 3, 200.0, "needs = [\"ISS-002\"]\n"); seed_valued(root, 10, 0.0, ""); seed_valued(root, 11, 10.0, "needs = [\"ISS-010\"]\n");
let g = build(root).unwrap();
let k = |id| EntityKey { prefix: "ISS", id };
let deep = channels::score(&g, k(1));
let shallow = channels::score(&g, k(10));
assert!(
(deep - 50.0 / 6.5).abs() < 1e-9,
"deep blocker recursive leverage = 50/6.5: got {deep}"
);
assert!(
(shallow - 5.0 / 6.5).abs() < 1e-9,
"shallow blocker leverage = 5/6.5: got {shallow}"
);
let ids = survey_ids(root);
let pd = ids.iter().position(|x| x == "ISS-001").unwrap();
let ps = ids.iter().position(|x| x == "ISS-010").unwrap();
assert!(
pd < ps,
"deep blocker of a valuable cone outranks the shallow one: {ids:?}"
);
}
#[test]
fn vt7_y_fixture_incomparable_arms_order_by_score() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 1, 0.0, "");
seed_valued(root, 2, 50.0, "after = [{ to = \"ISS-001\", rank = 0 }]\n");
seed_valued(root, 3, 100.0, "after = [{ to = \"ISS-001\", rank = 0 }]\n");
let ids = next_ids(root);
let p1 = ids.iter().position(|x| x == "ISS-001").unwrap();
let p2 = ids.iter().position(|x| x == "ISS-002").unwrap();
let p3 = ids.iter().position(|x| x == "ISS-003").unwrap();
assert!(p1 < p2 && p1 < p3, "shared upstream leads: {ids:?}");
assert!(p3 < p2, "higher-score arm ISS-003 before ISS-002: {ids:?}");
}
#[test]
fn vt7_same_chain_seq_keeps_structural_order_over_score() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 1, 10.0, "");
seed_valued(root, 2, 100.0, "after = [{ to = \"ISS-001\", rank = 0 }]\n");
let ids = next_ids(root);
let p1 = ids.iter().position(|x| x == "ISS-001").unwrap();
let p2 = ids.iter().position(|x| x == "ISS-002").unwrap();
assert!(
p1 < p2,
"low-score predecessor ISS-001 precedes high-score ISS-002 (structural): {ids:?}"
);
}
#[test]
fn vt7_evicted_seq_edge_does_not_reimpose_precedence() {
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 = build(root).unwrap();
let evicted_total: usize = g
.attrs
.keys()
.map(|&k| channels::evicted_seq_edges(&g, k).len())
.sum();
assert!(
evicted_total > 0,
"the seq 2-cycle must produce an eviction"
);
let ids = next_ids(root);
let p1 = ids.iter().position(|x| x == "ISS-001").unwrap();
let p2 = ids.iter().position(|x| x == "ISS-002").unwrap();
let surviving_pred_of_2 = {
let preds = surviving_seq_predecessors(
&g,
&g.attrs
.keys()
.copied()
.filter(|&k| channels::actionable(&g, k) && !channels::promoted(&g, k))
.collect(),
);
preds
.get(&EntityKey {
prefix: "ISS",
id: 2,
})
.map(|s| {
s.contains(&EntityKey {
prefix: "ISS",
id: 1,
})
})
.unwrap_or(false)
};
if surviving_pred_of_2 {
assert!(
p1 < p2,
"surviving edge orders ISS-001 before ISS-002: {ids:?}"
);
} else {
assert!(
p2 < p1,
"evicted edge does NOT re-impose precedence; higher-score ISS-002 leads: {ids:?}"
);
}
}
#[test]
fn beta_endpoints_some_over_interval_estimate_none_over_estimate_free() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 1, 10.0, "");
let scanned =
relation_graph::scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
let cfg = super::super::config::load(root);
assert!(
beta_endpoints(&scanned, root, &cfg).unwrap().is_some(),
"a non-terminal interval estimate yields Some"
);
let dir2 = tmp();
let root2 = dir2.path();
seed_issue(root2, 1, "open", "", &[]);
let scanned2 =
relation_graph::scan_entities(root2, &mut vec![], ScanMode::default()).unwrap();
let cfg2 = super::super::config::load(root2);
assert!(
beta_endpoints(&scanned2, root2, &cfg2).unwrap().is_none(),
"an estimate-free corpus yields None"
);
}
#[test]
fn beta_endpoints_none_when_only_terminal_has_interval() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/backlog/issue/001/backlog-001.toml",
"id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"closed\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[estimate]\nlower = 0.0\nupper = 10.0\n",
);
write(root, ".doctrine/backlog/issue/001/backlog-001.md", "b\n");
let scanned =
relation_graph::scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
let cfg = super::super::config::load(root);
assert!(
beta_endpoints(&scanned, root, &cfg).unwrap().is_none(),
"a terminal-only interval does not arm the sweep"
);
}
#[test]
fn va1_explain_exposes_full_score_breakdown() {
let dir = tmp();
let root = dir.path();
seed_valued(root, 1, 50.0, "");
seed_valued(root, 2, 100.0, "needs = [\"ISS-001\"]\n");
let ex = explain(root, "ISS-001").unwrap();
let expected_base = 50.0 / 6.5;
let expected_lev = 50.0 / 6.5;
match ex.score {
ReasonKind::Score {
base,
value_dim,
risk_dim,
leverage,
optionality,
total,
} => {
assert!((base - expected_base).abs() < 1e-9, "base = 50/6.5");
assert!(
(value_dim - expected_base).abs() < 1e-9,
"value_dim = 50/6.5"
);
assert!(risk_dim.abs() < 1e-9, "risk_dim 0");
assert!((leverage - expected_lev).abs() < 1e-9, "leverage = 50/6.5");
assert!(optionality.abs() < 1e-9, "optionality 0");
assert!(
(total - (expected_base + expected_lev)).abs() < 1e-9,
"total = base+lev"
);
}
other => panic!("explain score must be a Score reason, got {other:?}"),
}
let expected_total = expected_base + expected_lev;
let human = crate::priority::render::explain_human(&ex);
assert!(
human.contains(&format!(
"score: {expected_total:.1} (base {expected_base:.1} [value {expected_base:.1}, risk 0.0], leverage {expected_lev:.1}, optionality 0.0)"
)),
"human explain renders the full breakdown: {human}"
);
}
}