use std::collections::BTreeSet;
use crate::backlog_order::OverrideReason;
use crate::relation_graph::EntityKey;
use super::graph::PriorityGraph;
use super::partition::{StatusClass, status_class};
fn class_of(g: &PriorityGraph, node: EntityKey) -> StatusClass {
match g.attrs.get(&node) {
Some(attr) => status_class(attr.kind, attr.status.as_deref()),
None => StatusClass::Unrecognised,
}
}
pub(crate) fn eligible(g: &PriorityGraph, node: EntityKey) -> bool {
class_of(g, node) == StatusClass::Workable
}
pub(crate) fn promoted(g: &PriorityGraph, node: EntityKey) -> bool {
g.attrs.get(&node).is_some_and(|attr| attr.promoted)
}
pub(crate) fn blocked_by(g: &PriorityGraph, node: EntityKey) -> Vec<EntityKey> {
let Some(n) = g.projection.resolve(node) else {
return Vec::new();
};
g.graph
.in_edges(g.dep_overlay, n)
.filter_map(|(pred, _)| g.projection.key_of(pred))
.filter(|pred| class_of(g, *pred) != StatusClass::Terminal)
.collect::<BTreeSet<EntityKey>>()
.into_iter()
.collect()
}
pub(crate) fn blocked(g: &PriorityGraph, node: EntityKey) -> bool {
!blocked_by(g, node).is_empty()
}
pub(crate) fn actionable(g: &PriorityGraph, node: EntityKey) -> bool {
eligible(g, node) && !blocked(g, node)
}
pub(crate) fn blocking(g: &PriorityGraph, node: EntityKey) -> Vec<EntityKey> {
let Some(n) = g.projection.resolve(node) else {
return Vec::new();
};
g.graph
.out_edges(g.dep_overlay, n)
.filter_map(|(succ, _)| g.projection.key_of(succ))
.collect::<BTreeSet<EntityKey>>()
.into_iter()
.collect()
}
pub(crate) fn blocked_by_transitive(g: &PriorityGraph, node: EntityKey) -> Vec<EntityKey> {
let Some(n) = g.projection.resolve(node) else {
return Vec::new();
};
g.graph
.reachable(g.dep_overlay, n, cordage::Direction::Against)
.into_iter()
.filter_map(|pred| g.projection.key_of(pred))
.filter(|pred| class_of(g, *pred) != StatusClass::Terminal)
.collect::<BTreeSet<EntityKey>>()
.into_iter()
.collect()
}
pub(crate) fn blocking_transitive(g: &PriorityGraph, node: EntityKey) -> Vec<EntityKey> {
let Some(n) = g.projection.resolve(node) else {
return Vec::new();
};
g.graph
.reachable(g.dep_overlay, n, cordage::Direction::Along)
.into_iter()
.filter_map(|succ| g.projection.key_of(succ))
.collect::<BTreeSet<EntityKey>>()
.into_iter()
.collect()
}
pub(crate) fn evicted_seq_edges(
g: &PriorityGraph,
node: EntityKey,
) -> Vec<(EntityKey, EntityKey, OverrideReason)> {
let Some(n) = g.projection.resolve(node) else {
return Vec::new();
};
let mut out: Vec<(EntityKey, EntityKey, OverrideReason)> = g
.graph
.provenance()
.evictions()
.iter()
.filter(|e| e.overlay() == g.seq_overlay)
.filter(|e| e.edge().src() == n || e.edge().dst() == n)
.filter_map(|e| {
let from = g.projection.key_of(e.edge().src())?;
let to = g.projection.key_of(e.edge().dst())?;
let reason = match e.reason() {
cordage::EvictReason::IntraOverlayCycle => OverrideReason::SoftCycleEvicted,
cordage::EvictReason::UnionCycleVsLayer => OverrideReason::Contradicted,
cordage::EvictReason::ArityViolation => return None,
};
Some((from, to, reason))
})
.collect();
out.sort_by_key(|a| (a.0, a.1));
out
}
pub(crate) fn score(g: &PriorityGraph, node: EntityKey) -> f64 {
g.score.get(&node).copied().unwrap_or(0.0)
}
pub(crate) fn base(g: &PriorityGraph, node: EntityKey) -> f64 {
g.attrs.get(&node).map_or(0.0, |a| a.base_score.total())
}
pub(crate) fn value_dim(g: &PriorityGraph, node: EntityKey) -> f64 {
g.attrs.get(&node).map_or(0.0, |a| a.base_score.value_dim)
}
pub(crate) fn risk_dim(g: &PriorityGraph, node: EntityKey) -> f64 {
g.attrs.get(&node).map_or(0.0, |a| a.base_score.risk_dim)
}
pub(crate) fn leverage(g: &PriorityGraph, node: EntityKey) -> f64 {
g.leverage.get(&node).copied().unwrap_or(0.0)
}
pub(crate) fn optionality(g: &PriorityGraph, node: EntityKey) -> f64 {
g.optionality.get(&node).copied().unwrap_or(0.0)
}
pub(crate) fn dep_cycles(g: &PriorityGraph) -> Vec<BTreeSet<EntityKey>> {
g.graph
.provenance()
.cycles()
.iter()
.filter(|cycle| cycle.overlay() == g.dep_overlay)
.map(|cycle| g.projection.remap_set(cycle.nodes()))
.collect()
}
#[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 key(prefix: &'static str, id: u32) -> EntityKey {
EntityKey { prefix, id }
}
fn order_key(g: &PriorityGraph) -> Vec<EntityKey> {
g.graph
.ordered()
.iter()
.filter_map(|node| g.projection.key_of(*node))
.collect()
}
fn seed_slice(root: &Path, id: u32, status: &str, axes: &[(&str, &[&str])]) {
let rels = crate::relation::rels_block(&crate::slice::SLICE_KIND, axes);
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"s\"\ntitle = \"S\"\nstatus = \"{status}\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{rels}"
),
);
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.md"),
"scope\n",
);
}
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",
);
}
fn seed_risk(root: &Path, id: u32, status: &str, axes: &[(&str, &[&str])]) {
let rels = crate::relation::rels_block(&crate::backlog::RISK_KIND, axes);
write(
root,
&format!(".doctrine/backlog/risk/{id:03}/backlog-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"k\"\ntitle = \"K\"\nkind = \"risk\"\nstatus = \"{status}\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
{rels}"
),
);
write(
root,
&format!(".doctrine/backlog/risk/{id:03}/backlog-{id:03}.md"),
"k\n",
);
}
fn seed_requirement(root: &Path, id: u32) {
write(
root,
&format!(".doctrine/requirement/{id:03}/requirement-{id:03}.toml"),
&format!("id = {id}\nslug = \"r\"\ntitle = \"R\"\nstatus = \"active\"\n"),
);
write(
root,
&format!(".doctrine/requirement/{id:03}/requirement-{id:03}.md"),
"r\n",
);
}
fn seed_rec(root: &Path, id: u32, owning_slice: &str) {
write(
root,
&format!(".doctrine/rec/{id:03}/rec-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"r\"\ntitle = \"R\"\n\
[rec]\nmove = \"accept\"\nowning_slice = \"{owning_slice}\"\n"
),
);
}
#[test]
fn workable_unblocked_is_actionable() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[]);
let g = build(root).unwrap();
let n = key("ISS", 1);
assert!(eligible(&g, n), "open issue is eligible");
assert!(!blocked(&g, n), "no prereqs → not blocked");
assert!(actionable(&g, n), "workable + unblocked → actionable");
}
#[test]
fn workable_blocked_is_eligible_but_not_actionable() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["RSK-001"])]);
seed_risk(root, 1, "open", &[]);
let g = build(root).unwrap();
let n = key("ISS", 1);
assert!(eligible(&g, n), "open issue is eligible");
assert_eq!(blocked_by(&g, n), vec![key("RSK", 1)], "RSK-001 blocks");
assert!(blocked(&g, n));
assert!(!actionable(&g, n), "eligible but blocked → not actionable");
}
#[test]
fn terminal_prereq_does_not_block() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["RSK-001"])]);
seed_risk(root, 1, "closed", &[]);
let g = build(root).unwrap();
let n = key("ISS", 1);
assert!(
blocked_by(&g, n).is_empty(),
"a terminal prereq is satisfied, not a blocker"
);
assert!(actionable(&g, n), "satisfied prereq → actionable");
}
#[test]
fn terminal_node_is_not_eligible() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "done", &[]);
seed_issue(root, 1, "closed", "", &[]);
let g = build(root).unwrap();
assert!(!eligible(&g, key("SL", 1)), "done slice not eligible");
assert!(!actionable(&g, key("SL", 1)));
assert!(!eligible(&g, key("ISS", 1)), "closed issue not eligible");
}
#[test]
fn audit_and_reconcile_slices_are_workable() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "audit", &[]);
seed_slice(root, 2, "reconcile", &[]);
let g = build(root).unwrap();
assert!(eligible(&g, key("SL", 1)), "audit slice is workable");
assert!(actionable(&g, key("SL", 1)));
assert!(eligible(&g, key("SL", 2)), "reconcile slice is workable");
assert!(actionable(&g, key("SL", 2)));
}
#[test]
fn rec_status_less_is_not_eligible() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "proposed", &[]);
seed_rec(root, 1, "SL-001");
let g = build(root).unwrap();
assert!(!eligible(&g, key("REC", 1)), "status-less REC not eligible");
assert!(!actionable(&g, key("REC", 1)));
}
#[test]
fn unrecognised_slice_status_is_not_eligible() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "frobnicate", &[]);
let g = build(root).unwrap();
assert!(
!eligible(&g, key("SL", 1)),
"unrecognised status → not eligible"
);
}
#[test]
fn promoted_backlog_node_surfaces_its_own_reason() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "resolved", "promoted", &[]);
seed_issue(root, 2, "open", "", &[]);
let g = build(root).unwrap();
assert!(
promoted(&g, key("ISS", 1)),
"resolution=promoted ⇒ promoted"
);
assert!(
!promoted(&g, key("ISS", 2)),
"plain open issue not promoted"
);
}
#[test]
fn blocking_lists_dependents() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["RSK-001"])]);
seed_risk(root, 1, "open", &[]);
let g = build(root).unwrap();
assert_eq!(
blocking(&g, key("RSK", 1)),
vec![key("ISS", 1)],
"RSK-001 blocks ISS-001"
);
assert!(
blocking(&g, key("ISS", 1)).is_empty(),
"ISS-001 blocks nothing"
);
}
#[test]
fn score_reads_the_post_pass_value() {
let dir = tmp();
let root = dir.path();
seed_slice(
root,
1,
"proposed",
&[("references(implements)", &["REQ-005"])],
);
seed_requirement(root, 5);
let g = build(root).unwrap();
assert_eq!(score(&g, key("REQ", 5)), 0.0, "no facets → score floor 0");
assert_eq!(base(&g, key("REQ", 5)), 0.0);
assert_eq!(leverage(&g, key("REQ", 5)), 0.0);
assert_eq!(optionality(&g, key("REQ", 5)), 0.0);
}
#[test]
fn dep_cycle_named_fallback_order_no_false_topo() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-002"])]);
seed_issue(root, 2, "open", "", &[("needs", &["ISS-001"])]);
seed_slice(root, 9, "proposed", &[]);
let g = build(root).unwrap();
let cycles = dep_cycles(&g);
assert_eq!(cycles.len(), 1, "exactly one dep cycle");
let component = &cycles[0];
assert!(component.contains(&key("ISS", 1)));
assert!(component.contains(&key("ISS", 2)));
let order = order_key(&g);
assert!(order.contains(&key("ISS", 1)));
assert!(order.contains(&key("ISS", 2)));
assert!(order.contains(&key("SL", 9)), "acyclic node still ordered");
assert_eq!(
order.len(),
3,
"every node appears exactly once in the order"
);
}
#[test]
fn no_cycle_means_no_diagnostic() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["RSK-001"])]);
seed_risk(root, 1, "open", &[]);
let g = build(root).unwrap();
assert!(dep_cycles(&g).is_empty(), "acyclic corpus → no cycles");
}
#[test]
fn channels_are_permutation_invariant() {
let build_corpus = |authoring: u8| {
let dir = tmp();
let root = dir.path().to_path_buf();
let pieces: [&dyn Fn(&Path); 5] = [
&|r: &Path| seed_issue(r, 1, "open", "", &[("needs", &["RSK-001"])]),
&|r: &Path| {
write(
r,
".doctrine/backlog/issue/002/backlog-002.toml",
"id = 2\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nafter = [{ to = \"ISS-001\", rank = 0 }]\n",
);
write(r, ".doctrine/backlog/issue/002/backlog-002.md", "b\n");
},
&|r: &Path| seed_risk(r, 1, "open", &[]),
&|r: &Path| seed_slice(r, 5, "design", &[("references(implements)", &["REQ-007"])]),
&|r: &Path| seed_requirement(r, 7),
];
if authoring == 0 {
for p in &pieces {
p(&root);
}
} else {
for p in pieces.iter().rev() {
p(&root);
}
}
(dir, build(&root).unwrap())
};
let (_d0, g0) = build_corpus(0);
let (_d1, g1) = build_corpus(1);
let nodes = [
key("ISS", 1),
key("ISS", 2),
key("RSK", 1),
key("SL", 5),
key("REQ", 7),
];
for n in nodes {
assert_eq!(eligible(&g0, n), eligible(&g1, n), "eligible {n:?}");
assert_eq!(actionable(&g0, n), actionable(&g1, n), "actionable {n:?}");
assert_eq!(blocked_by(&g0, n), blocked_by(&g1, n), "blocked_by {n:?}");
assert_eq!(blocking(&g0, n), blocking(&g1, n), "blocking {n:?}");
assert_eq!(score(&g0, n), score(&g1, n), "score {n:?}");
}
assert_eq!(order_key(&g0), order_key(&g1), "order_key invariant");
assert_eq!(dep_cycles(&g0), dep_cycles(&g1), "dep_cycles invariant");
}
}