use std::path::Path;
use crate::relation_graph::{self, EntityKey};
use super::channels;
use super::graph::{self, NodeAttr, PriorityGraph};
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) -> anyhow::Result<Vec<SurveyRow>> {
let g = graph::build(root)?;
Ok(survey_for_map(&g, all))
}
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,
}
}
fn surviving_seq_predecessors(
g: &PriorityGraph,
actionable: &std::collections::BTreeSet<EntityKey>,
) -> std::collections::BTreeMap<EntityKey, std::collections::BTreeSet<EntityKey>> {
let mut preds: std::collections::BTreeMap<EntityKey, std::collections::BTreeSet<EntityKey>> =
std::collections::BTreeMap::new();
for &k in actionable {
let evicted: std::collections::BTreeSet<(EntityKey, EntityKey)> =
channels::evicted_seq_edges(g, k)
.into_iter()
.map(|(from, to, _reason)| (from, to))
.collect();
let mut set = std::collections::BTreeSet::new();
if let Some(n) = g.projection.resolve(k) {
for (pred, _) in g.graph.in_edges(g.seq_overlay, n) {
if let Some(pk) = g.projection.key_of(pred)
&& actionable.contains(&pk)
&& !evicted.contains(&(pk, k))
{
set.insert(pk);
}
}
}
preds.insert(k, set);
}
preds
}
fn frontier_order(
actionable: &[EntityKey],
score: &dyn Fn(EntityKey) -> f64,
preds: &std::collections::BTreeMap<EntityKey, std::collections::BTreeSet<EntityKey>>,
) -> Vec<EntityKey> {
let mut emitted: std::collections::BTreeSet<EntityKey> = std::collections::BTreeSet::new();
let mut out: Vec<EntityKey> = Vec::with_capacity(actionable.len());
while out.len() < actionable.len() {
let ready: Vec<EntityKey> = actionable
.iter()
.copied()
.filter(|k| !emitted.contains(k))
.filter(|k| {
preds
.get(k)
.is_none_or(|ps| ps.iter().all(|p| emitted.contains(p)))
})
.collect();
let candidates: Vec<EntityKey> = if ready.is_empty() {
actionable
.iter()
.copied()
.filter(|k| !emitted.contains(k))
.collect()
} else {
ready
};
let Some(pick) = candidates.into_iter().max_by(|a, b| {
score(*a).total_cmp(&score(*b)).then_with(|| b.cmp(a))
}) else {
break;
};
emitted.insert(pick);
out.push(pick);
}
out
}
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),
})
}
#[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).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)
.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 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}"
);
}
}