use std::collections::BTreeMap;
use crate::catalog::scan::ScanMode;
use cordage::{
Arity, CyclePolicy, Direction, EdgeAttrs, Graph, GraphBuilder, OrderLayer, OrderSpec,
OverlayConfig, OverlayId,
};
use crate::facet::EntityFacets;
use crate::priority::config;
use crate::projection::Projection;
use crate::relation::RelationLabel;
use crate::relation_graph::{self, EntityKey};
use crate::{dep_seq, entity, integrity};
#[derive(Debug, Clone, Copy)]
pub(crate) struct BaseScore {
pub(crate) value_dim: f64,
pub(crate) risk_dim: f64,
}
impl BaseScore {
pub(crate) fn total(&self) -> f64 {
let t = self.value_dim + self.risk_dim;
if t.is_finite() { t } else { 0.0 }
}
}
fn base_score(f: &EntityFacets, kind: &entity::Kind, cfg: &config::PriorityConfig) -> BaseScore {
const EPSILON: f64 = 1e-12;
let tag_term = (1.0 + f.tags.iter().map(|t| cfg.tag_coeff(t) - 1.0).sum::<f64>()).max(0.0);
let value_dim = {
let raw = if let Some(ref v) = f.value {
let est_mid = match f.estimate {
Some(ref e) => {
let m = f64::midpoint(e.lower, e.upper);
if m < EPSILON { EPSILON } else { m }
}
None => 1.0,
};
let kw = cfg.kind_weight(kind.prefix);
cfg.coefficients.value * v.value * kw * tag_term / est_mid
} else {
0.0
};
if raw.is_finite() { raw } else { 0.0 }
};
let risk_dim = {
let raw = cfg.coefficients.risk * f64::from(crate::risk::exposure(f.risk.as_ref()));
if raw.is_finite() { raw } else { 0.0 }
};
BaseScore {
value_dim,
risk_dim,
}
}
pub(crate) struct NodeAttr {
pub(crate) kind: &'static entity::Kind,
pub(crate) status: Option<String>,
pub(crate) promoted: bool,
pub(crate) title: String,
pub(crate) base_score: BaseScore,
}
pub(crate) struct PriorityGraph {
pub(crate) graph: Graph,
pub(crate) projection: Projection<EntityKey>,
pub(crate) attrs: BTreeMap<EntityKey, NodeAttr>,
pub(crate) leverage: BTreeMap<EntityKey, f64>,
pub(crate) optionality: BTreeMap<EntityKey, f64>,
pub(crate) score: BTreeMap<EntityKey, f64>,
pub(crate) dep_overlay: OverlayId,
pub(crate) seq_overlay: OverlayId,
}
const REF_LABELS: &[RelationLabel] = &[
RelationLabel::References,
RelationLabel::Supersedes,
RelationLabel::DescendsFrom,
RelationLabel::Parent,
RelationLabel::Members,
RelationLabel::Interactions,
RelationLabel::Slices,
RelationLabel::Related,
RelationLabel::Reviews,
RelationLabel::OwningSlice,
];
const CONSEQUENCE_LABELS: &[RelationLabel] = &[
RelationLabel::References,
RelationLabel::Slices,
RelationLabel::DescendsFrom,
RelationLabel::Parent,
RelationLabel::Members,
];
pub(crate) fn build(root: &std::path::Path) -> anyhow::Result<PriorityGraph> {
build_from(
&relation_graph::scan_entities(root, &mut vec![], ScanMode::default())?,
root,
)
}
pub(crate) fn build_from(
scanned: &[relation_graph::ScannedEntity],
root: &std::path::Path,
) -> anyhow::Result<PriorityGraph> {
let cfg = config::load(root);
let base_by_key: BTreeMap<EntityKey, BaseScore> = scanned
.iter()
.map(|entity| {
let base = base_score(
&EntityFacets {
estimate: entity.estimate.clone(),
value: entity.value.clone(),
risk: entity.risk.clone(),
tags: entity.tags.clone(),
},
entity.kind,
&cfg,
);
(entity.key, base)
})
.collect();
let mut order: Vec<EntityKey> = scanned.iter().map(|e| e.key).collect();
order.sort_by(|a, b| {
let ba = base_by_key.get(a).map_or(0.0, BaseScore::total);
let bb = base_by_key.get(b).map_or(0.0, BaseScore::total);
bb.total_cmp(&ba).then_with(|| a.cmp(b))
});
let mut builder = GraphBuilder::new();
let mut ref_by_label: BTreeMap<RelationLabel, OverlayId> = BTreeMap::new();
for &label in REF_LABELS {
let ov = builder.overlay(OverlayConfig::new(CyclePolicy::Reject, Arity::Unbounded));
ref_by_label.insert(label, ov);
}
let dep_overlay = builder.overlay(OverlayConfig::new(CyclePolicy::Reject, Arity::Unbounded));
let seq_overlay = builder.overlay(OverlayConfig::new(CyclePolicy::Evict, Arity::Unbounded));
let mut projection: Projection<EntityKey> = Projection::new();
for &key in &order {
assert!(
projection.resolve(key).is_none(),
"priority::graph: duplicate EntityKey {} (canonical ids unique by prefix)",
key.canonical()
);
projection.intern(&mut builder, key);
}
let mut dep_seq: BTreeMap<EntityKey, (dep_seq::DepSeq, bool)> = BTreeMap::new();
for entity in scanned {
dep_seq.insert(
entity.key,
relation_graph::dep_seq_for(root, entity.kind, entity.key.id)?,
);
}
let mut attrs: BTreeMap<EntityKey, NodeAttr> = BTreeMap::new();
for entity in scanned {
let base = base_by_key.get(&entity.key).copied().unwrap_or(BaseScore {
value_dim: 0.0,
risk_dim: 0.0,
});
attrs.insert(
entity.key,
NodeAttr {
kind: entity.kind,
status: entity.status.clone(),
promoted: dep_seq
.get(&entity.key)
.is_some_and(|(_ds, promoted)| *promoted),
title: entity.title.clone(),
base_score: base,
},
);
}
for entity in scanned {
let Some(src) = projection.resolve(entity.key) else {
debug_assert!(false, "priority::graph: edge-pass key not interned");
continue;
};
for edge in &entity.outbound {
if let Some(dst) = resolve(&projection, &edge.target)
&& let Some(&ov) = ref_by_label.get(&edge.label)
{
builder.edge(ov, src, dst, EdgeAttrs::new(0, 0));
}
}
if let Some((ds, _promoted)) = dep_seq.get(&entity.key) {
for prereq_ref in &ds.needs {
if let Some(prereq) = resolve(&projection, prereq_ref) {
builder.edge(dep_overlay, prereq, src, EdgeAttrs::new(0, 0));
}
}
for (idx, edge) in ds.after.iter().enumerate() {
if let Some(prereq) = resolve(&projection, &edge.to) {
let age = u64::try_from(idx).map_err(|e| {
anyhow::anyhow!("priority::graph: after-edge index overflows u64: {e}")
})?;
builder.edge(seq_overlay, prereq, src, EdgeAttrs::new(edge.rank, age));
}
}
}
}
builder.order_spec(OrderSpec::new(vec![
OrderLayer::new(dep_overlay, Direction::Along),
OrderLayer::new(seq_overlay, Direction::Along),
]));
let graph = builder.build().map_err(|e| {
anyhow::anyhow!(
"priority::graph: cordage rejected well-formed adapter input (internal bug): {e:?}"
)
})?;
let (leverage, optionality, score) = consequence_post_pass(
&graph,
&projection,
&attrs,
&ref_by_label,
dep_overlay,
&cfg,
);
Ok(PriorityGraph {
graph,
projection,
attrs,
leverage,
optionality,
score,
dep_overlay,
seq_overlay,
})
}
fn consequence_post_pass(
graph: &Graph,
projection: &Projection<EntityKey>,
attrs: &BTreeMap<EntityKey, NodeAttr>,
ref_by_label: &BTreeMap<RelationLabel, OverlayId>,
dep_overlay: OverlayId,
cfg: &config::PriorityConfig,
) -> (
BTreeMap<EntityKey, f64>,
BTreeMap<EntityKey, f64>,
BTreeMap<EntityKey, f64>,
) {
use std::collections::BTreeSet;
let ek = |nid: cordage::NodeId| -> Option<EntityKey> { projection.key_of(nid) };
let base_of = |nid: cordage::NodeId| -> f64 {
ek(nid)
.and_then(|k| attrs.get(&k))
.map_or(0.0, |a| a.base_score.total())
};
let cycles = graph.provenance().cycles();
let mut node_to_component: BTreeMap<cordage::NodeId, usize> = BTreeMap::new();
let mut component_members: Vec<BTreeSet<cordage::NodeId>> = Vec::new();
for cyc in cycles {
if cyc.overlay() != dep_overlay {
continue;
}
let comp_idx = component_members.len();
for &n in cyc.nodes() {
node_to_component.insert(n, comp_idx);
}
component_members.push(cyc.nodes().clone());
}
for nid in graph.ordered() {
node_to_component.entry(nid).or_insert_with(|| {
let comp_idx = component_members.len();
component_members.push(BTreeSet::from([nid]));
comp_idx
});
}
let component_count = component_members.len();
let comp_of = |nid: cordage::NodeId| -> Option<usize> { node_to_component.get(&nid).copied() };
let mut comp_dependents: Vec<BTreeSet<cordage::NodeId>> =
vec![BTreeSet::new(); component_count];
let mut comp_succ: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); component_count];
for (c, ((dependents, succ), members)) in comp_dependents
.iter_mut()
.zip(comp_succ.iter_mut())
.zip(component_members.iter())
.enumerate()
{
for &m in members {
for (d, _) in graph.out_edges(dep_overlay, m) {
match comp_of(d) {
Some(dc) if dc != c => {
dependents.insert(d);
succ.insert(dc);
}
_ => {} }
}
}
}
let mut topo: Vec<usize> = Vec::with_capacity(component_count);
let mut visited = vec![false; component_count];
for start in 0..component_count {
if visited.get(start).copied().unwrap_or(true) {
continue;
}
let mut stack: Vec<(usize, bool)> = vec![(start, false)];
while let Some((c, emit)) = stack.pop() {
if emit {
topo.push(c);
continue;
}
if visited.get(c).copied().unwrap_or(true) {
continue;
}
if let Some(slot) = visited.get_mut(c) {
*slot = true;
}
stack.push((c, true));
if let Some(succ) = comp_succ.get(c) {
for &sc in succ {
if !visited.get(sc).copied().unwrap_or(true) {
stack.push((sc, false));
}
}
}
}
}
let mut leverage_by_node: BTreeMap<cordage::NodeId, f64> = BTreeMap::new();
for &c in &topo {
let Some(dependents) = comp_dependents.get(c) else {
continue;
};
let mut sum = 0.0f64;
for &d in dependents {
sum += base_of(d) + leverage_by_node.get(&d).copied().unwrap_or(0.0);
}
let lev = cfg.consequence.dep_coeff * sum;
let lev = if lev.is_finite() { lev } else { 0.0 };
if let Some(members) = component_members.get(c) {
for &m in members {
leverage_by_node.insert(m, lev);
}
}
}
let mut optionality_by_node: BTreeMap<cordage::NodeId, f64> = BTreeMap::new();
for nid in graph.ordered() {
let mut sum = 0.0f64;
for &label in CONSEQUENCE_LABELS {
if let Some(&ov) = ref_by_label.get(&label) {
for (src, _) in graph.in_edges(ov, nid) {
sum += base_of(src);
}
}
}
let opt = cfg.consequence.ref_coeff * sum;
let opt = if opt.is_finite() { opt } else { 0.0 };
optionality_by_node.insert(nid, opt);
}
let mut leverage: BTreeMap<EntityKey, f64> = BTreeMap::new();
let mut optionality: BTreeMap<EntityKey, f64> = BTreeMap::new();
let mut score: BTreeMap<EntityKey, f64> = BTreeMap::new();
for nid in graph.ordered() {
if let Some(k) = ek(nid) {
let lev = leverage_by_node.get(&nid).copied().unwrap_or(0.0);
let opt = optionality_by_node.get(&nid).copied().unwrap_or(0.0);
let bs = base_of(nid);
let sc = bs + lev + opt;
let sc = if sc.is_finite() { sc } else { 0.0 };
leverage.insert(k, lev);
optionality.insert(k, opt);
score.insert(k, sc);
}
}
(leverage, optionality, score)
}
fn resolve(projection: &Projection<EntityKey>, reference: &str) -> Option<cordage::NodeId> {
let (kref, id) = integrity::parse_canonical_ref(reference).ok()?;
projection.resolve(EntityKey {
prefix: kref.kind.prefix,
id,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
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 migrate_body(source: &crate::entity::Kind, rels: &str) -> String {
use crate::relation::RelationLabel;
let mut typed = String::new();
let mut rows = String::new();
for line in rels.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let key = trimmed.split('=').next().unwrap_or("").trim();
let is_simple_list = trimmed.contains('[') && !trimmed.contains('{');
let migrated = is_simple_list
&& RelationLabel::from_name(key)
.and_then(|l| crate::relation::lookup(source, l, None))
.is_some_and(|r| {
r.tier == crate::relation::Tier::One
&& r.link != crate::relation::LinkPolicy::LifecycleOnly
});
if migrated {
let inner = trimmed
.split_once('[')
.and_then(|(_, rest)| rest.rsplit_once(']'))
.map(|(refs, _)| refs)
.unwrap_or("");
for t in inner.split(',') {
let t = t.trim().trim_matches('"');
if !t.is_empty() {
rows.push_str(&format!(
"[[relation]]\nlabel = \"{key}\"\ntarget = \"{t}\"\n"
));
}
}
} else {
typed.push_str(line);
typed.push('\n');
}
}
let typed_table = if typed.trim().is_empty() {
String::new()
} else {
format!("[relationships]\n{typed}")
};
format!("{typed_table}{rows}")
}
fn seed_slice(root: &Path, id: u32, rels: &str) {
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{}",
migrate_body(&crate::slice::SLICE_KIND, rels)
),
);
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.md"),
"scope\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_issue(root: &Path, id: u32, status: &str, resolution: &str, rels: &str) {
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\
{}",
migrate_body(&crate::backlog::ISSUE_KIND, rels)
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
fn seed_risk(root: &Path, id: u32, status: &str, rels: &str) {
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\
{}",
migrate_body(&crate::backlog::RISK_KIND, rels)
),
);
write(
root,
&format!(".doctrine/backlog/risk/{id:03}/backlog-{id:03}.md"),
"k\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"
),
);
}
fn seed_review(root: &Path, id: u32, target: &str, findings: &str) {
write(
root,
&format!(".doctrine/review/{id:03}/review-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"r\"\ntitle = \"R\"\n\
[review]\nfacet = \"reconciliation\"\nraiser = \"a\"\nresponder = \"b\"\n\
[target]\nref = \"{target}\"\n{findings}"
),
);
}
fn key(prefix: &'static str, id: u32) -> EntityKey {
EntityKey { prefix, id }
}
#[test]
fn builds_over_multi_kind_corpus_node_set_equals_scanned() {
let dir = tmp();
let root = dir.path();
seed_slice(
root,
1,
"[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-005\"\n",
);
seed_requirement(root, 5);
seed_issue(root, 1, "open", "", "slices = [\"SL-001\"]\n");
seed_rec(root, 1, "SL-001");
seed_review(root, 1, "SL-001", "");
let pg = build(root).unwrap();
let scanned: std::collections::BTreeSet<EntityKey> =
relation_graph::scan_entities(root, &mut vec![], ScanMode::default())
.unwrap()
.iter()
.map(|e| e.key)
.collect();
let minted: std::collections::BTreeSet<EntityKey> = pg.attrs.keys().copied().collect();
assert_eq!(minted, scanned, "every scanned entity is a node");
for k in &scanned {
assert!(
pg.projection.resolve(*k).is_some(),
"{} minted",
k.canonical()
);
}
assert_eq!(pg.attrs.len(), scanned.len());
for (k, attr) in &pg.attrs {
assert_eq!(
attr.kind.prefix, k.prefix,
"NodeAttr.kind matches the key prefix"
);
}
}
#[test]
fn node_attr_status_promoted_per_kind() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "");
seed_requirement(root, 5);
seed_issue(root, 1, "resolved", "promoted", "");
seed_issue(root, 2, "open", "", "");
seed_rec(root, 1, "SL-001");
seed_review(
root,
1,
"SL-001",
"[[finding]]\nid = \"F-1\"\nstatus = \"open\"\nseverity = \"minor\"\n\
title = \"t\"\ndetail = \"d\"\n",
);
seed_review(
root,
2,
"SL-001",
"[[finding]]\nid = \"F-1\"\nstatus = \"verified\"\nseverity = \"minor\"\n\
title = \"t\"\ndetail = \"d\"\n",
);
let pg = build(root).unwrap();
assert_eq!(pg.attrs[&key("SL", 1)].status.as_deref(), Some("proposed"));
assert!(!pg.attrs[&key("SL", 1)].promoted);
assert_eq!(pg.attrs[&key("REQ", 5)].status.as_deref(), Some("active"));
assert_eq!(pg.attrs[&key("REC", 1)].status, None);
assert_eq!(pg.attrs[&key("ISS", 1)].status.as_deref(), Some("resolved"));
assert!(
pg.attrs[&key("ISS", 1)].promoted,
"resolution=promoted ⇒ promoted"
);
assert!(!pg.attrs[&key("ISS", 2)].promoted);
assert_eq!(pg.attrs[&key("RV", 1)].status.as_deref(), Some("active"));
assert_eq!(pg.attrs[&key("RV", 2)].status.as_deref(), Some("done"));
}
#[test]
fn mint_order_base_desc_then_canonical_asc_and_permutation_invariant() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "value = 5.0", "");
seed_issue_with_facets(root, 2, "", "lower = 0.0\nupper = 10.0", "value = 25.0", "");
seed_issue_with_facets(root, 3, "", "lower = 0.0\nupper = 10.0", "value = 15.0", "");
let pg = build(root).unwrap();
let n1 = pg.projection.resolve(key("ISS", 1)).unwrap();
let n2 = pg.projection.resolve(key("ISS", 2)).unwrap();
let n3 = pg.projection.resolve(key("ISS", 3)).unwrap();
assert!(
n2 < n3,
"ISS-002 (base 5.0) mints before ISS-003 (base 3.0)"
);
assert!(
n3 < n1,
"ISS-003 (base 3.0) mints before ISS-001 (base 1.0)"
);
let dir2 = tmp();
let root2 = dir2.path();
seed_issue_with_facets(
root2,
3,
"",
"lower = 0.0\nupper = 10.0",
"value = 15.0",
"",
);
seed_issue_with_facets(
root2,
2,
"",
"lower = 0.0\nupper = 10.0",
"value = 25.0",
"",
);
seed_issue_with_facets(root2, 1, "", "lower = 0.0\nupper = 10.0", "value = 5.0", "");
let pg2 = build(root2).unwrap();
assert_eq!(pg.score, pg2.score, "score map is permutation-invariant");
let m1 = pg2.projection.resolve(key("ISS", 1)).unwrap();
let m2 = pg2.projection.resolve(key("ISS", 2)).unwrap();
let m3 = pg2.projection.resolve(key("ISS", 3)).unwrap();
assert!(m2 < m3 && m3 < m1, "mint order is permutation-invariant");
}
#[test]
fn mint_order_is_blind_to_consequence_topology() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", "");
seed_issue_with_facets(root, 2, "", "lower = 0.0\nupper = 10.0", "value = 25.0", "");
seed_slice(root, 1, "slices = [\"ISS-001\"]\n");
seed_slice(root, 2, "slices = [\"ISS-001\"]\n");
let pg = build(root).unwrap();
let n1 = pg.projection.resolve(key("ISS", 1)).unwrap();
let n2 = pg.projection.resolve(key("ISS", 2)).unwrap();
assert!(
n2 < n1,
"ISS-002 (base 5.0) mints before the heavily-referenced ISS-001 (base 0) — mint is base-only (I3)"
);
assert_eq!(pg.score.get(&key("ISS", 1)).copied().unwrap_or(0.0), 0.0);
}
#[test]
fn dep_seq_edges_emitted_for_backlog_unresolved_contributes_no_edge() {
let dir = tmp();
let root = dir.path();
seed_issue(
root,
1,
"open",
"",
"needs = [\"RSK-001\", \"ISS-099\"]\nafter = [{ to = \"ISS-002\", rank = 0 }]\n",
);
seed_issue(root, 2, "open", "", "");
seed_risk(root, 1, "open", "");
let pg = build(root).unwrap();
let iss1 = pg.projection.resolve(key("ISS", 1)).unwrap();
let rsk1 = pg.projection.resolve(key("RSK", 1)).unwrap();
let dep_preds: Vec<_> = pg
.graph
.in_edges(pg.dep_overlay, iss1)
.map(|(s, _)| s)
.collect();
assert_eq!(
dep_preds,
vec![rsk1],
"only the resolvable needs prereq edges (B→A); unresolved adds nothing"
);
let iss2 = pg.projection.resolve(key("ISS", 2)).unwrap();
let seq_preds: Vec<_> = pg
.graph
.in_edges(pg.seq_overlay, iss1)
.map(|(s, _)| s)
.collect();
assert!(
seq_preds.contains(&iss2),
"after edge oriented predecessor→src"
);
}
#[test]
fn nodes_authoring_no_dep_seq_carry_no_edges() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(
root,
1,
"slices = [\"ISS-002\"]\n",
"lower = 0.0\nupper = 10.0",
"value = 25.0",
"",
);
seed_issue(root, 2, "open", "", "");
seed_slice(
root,
1,
"[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-005\"\n",
);
seed_requirement(root, 5);
seed_slice(root, 2, "");
let pg = build(root).unwrap();
let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
let sl2 = pg.projection.resolve(key("SL", 2)).unwrap();
assert_eq!(pg.graph.in_edges(pg.dep_overlay, sl1).count(), 0);
assert_eq!(pg.graph.in_edges(pg.seq_overlay, sl1).count(), 0);
assert_eq!(pg.graph.in_edges(pg.dep_overlay, sl2).count(), 0);
assert!(
(pg.optionality.get(&key("ISS", 2)).copied().unwrap_or(0.0) - 5.0).abs() < 1e-9,
"resolvable consequence ref produces its edge (witnessed via optionality)"
);
}
#[test]
fn slice_needs_lands_on_dep_overlay_cross_kind() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "needs = [\"SL-002\"]\n");
seed_slice(root, 2, "");
let pg = build(root).unwrap();
let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
let sl2 = pg.projection.resolve(key("SL", 2)).unwrap();
let dep_preds: Vec<_> = pg
.graph
.in_edges(pg.dep_overlay, sl1)
.map(|(s, _)| s)
.collect();
assert_eq!(
dep_preds,
vec![sl2],
"slice→slice needs lands on the dep overlay (B→A flip), like backlog"
);
}
#[test]
fn slice_after_lands_on_seq_overlay_with_rank_and_array_index_age() {
let dir = tmp();
let root = dir.path();
seed_slice(
root,
1,
"after = [{ to = \"SL-002\", rank = 7 }, { to = \"SL-003\" }]\n",
);
seed_slice(root, 2, "");
seed_slice(root, 3, "");
let pg = build(root).unwrap();
let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
let sl2 = pg.projection.resolve(key("SL", 2)).unwrap();
let sl3 = pg.projection.resolve(key("SL", 3)).unwrap();
let seq: BTreeMap<_, _> = pg
.graph
.in_edges(pg.seq_overlay, sl1)
.map(|(s, a)| (s, (a.rank(), a.age())))
.collect();
assert_eq!(
seq.get(&sl2).copied(),
Some((7, 0)),
"first after edge: authored rank 7, age = array index 0"
);
assert_eq!(
seq.get(&sl3).copied(),
Some((0, 1)),
"second after edge: default rank 0, age = array index 1"
);
}
#[test]
fn free_text_outbound_target_produces_no_edge() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", "drift = [\"some-free-text\"]\n");
let pg = build(root).unwrap();
let n = pg.projection.resolve(key("ISS", 1)).unwrap();
assert_eq!(
pg.graph.out_edges(pg.dep_overlay, n).count(),
0,
"free-text drift target produces no dep edge"
);
assert_eq!(
pg.score.get(&key("ISS", 1)).copied().unwrap_or(0.0),
0.0,
"free-text drift target produces no edge → score floor 0"
);
}
fn seed_issue_with_facets(
root: &Path,
id: u32,
rels: &str,
estimate: &str,
value: &str,
risk_facet: &str,
) {
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
{}\n[estimate]\n{}\n[value]\n{}\n[facet]\n{}\n",
migrate_body(&crate::backlog::ISSUE_KIND, rels),
estimate,
value,
risk_facet,
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
#[test]
fn base_score_all_facets_present() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(
root,
1,
"",
"lower = 2.0\nupper = 8.0",
"value = 10.0",
"likelihood = \"high\"\nimpact = \"critical\"",
);
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!((bs.value_dim - 2.0).abs() < 1e-9, "value_dim should be 2.0");
assert!((bs.risk_dim - 24.0).abs() < 1e-9, "risk_dim should be 24.0");
assert!((bs.total() - 26.0).abs() < 1e-9, "total should be 26.0");
}
#[test]
fn base_score_value_only_risk_absent() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 2.0", "value = 5.0", "");
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!((bs.value_dim - 5.0).abs() < 1e-9, "value_dim should be 5.0");
assert!((bs.risk_dim - 0.0).abs() < 1e-9, "risk_dim should be 0");
assert!((bs.total() - 5.0).abs() < 1e-9, "total should be 5.0");
}
#[test]
fn base_score_risk_only_value_absent() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(
root,
1,
"",
"",
"",
"likelihood = \"low\"\nimpact = \"medium\"",
);
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!((bs.value_dim - 0.0).abs() < 1e-9, "value_dim should be 0");
assert!((bs.risk_dim - 4.0).abs() < 1e-9, "risk_dim should be 4.0");
assert!((bs.total() - 4.0).abs() < 1e-9, "total should be 4.0");
}
#[test]
fn base_score_neither_facet_present() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", "");
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!((bs.value_dim - 0.0).abs() < 1e-9, "value_dim should be 0");
assert!((bs.risk_dim - 0.0).abs() < 1e-9, "risk_dim should be 0");
assert!((bs.total() - 0.0).abs() < 1e-9, "total should be 0");
}
#[test]
fn base_score_absent_estimate_uses_midpoint_one() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(
root,
1,
"",
"", "value = 3.0",
"",
);
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!((bs.value_dim - 3.0).abs() < 1e-9, "value_dim should be 3.0");
}
#[test]
fn leverage_flows_out_edges_dep_overlay() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 10.0", "");
seed_issue_with_facets(root, 2, "", "", "value = 3.0", "");
let pg = build(root).unwrap();
let lev2 = pg.leverage[&key("ISS", 2)];
let lev1 = pg.leverage[&key("ISS", 1)];
assert!((lev1 - 0.0).abs() < 1e-9, "ISS-001 has no dependents");
assert!((lev2 - 5.0).abs() < 1e-9, "ISS-002 gets 0.5 * 10.0");
}
#[test]
fn optionality_flows_in_edges_over_consequence_labels_one_hop() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "slices = [\"ISS-001\"]\n");
seed_issue_with_facets(root, 1, "", "", "value = 7.0", "");
let pg = build(root).unwrap();
let opt = pg.optionality[&key("ISS", 1)];
assert!(
(opt - 0.0).abs() < 1e-9,
"SL-001 has no value → optionality=0"
);
let opt_sl = pg.optionality[&key("SL", 1)];
assert!(
(opt_sl - 0.0).abs() < 1e-9,
"SL-001 is not a ref target of a consequence label"
);
}
#[test]
fn reviews_and_owning_slice_edges_contribute_zero_optionality() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "", "value = 5.0", "");
seed_review(root, 1, "ISS-001", "");
seed_rec(root, 1, "ISS-001");
let pg = build(root).unwrap();
let opt = pg.optionality[&key("ISS", 1)];
assert!(
(opt - 0.0).abs() < 1e-9,
"reviews/owning_slice contribute 0"
);
}
#[test]
fn dangling_target_contributes_zero() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "slices = [\"ISS-099\"]\n");
seed_issue_with_facets(root, 1, "", "", "value = 3.0", "");
let pg = build(root).unwrap();
assert!(pg.optionality.get(&key("ISS", 1)).copied().unwrap_or(0.0) == 0.0);
}
#[test]
fn leverage_recursive_chain() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 2.0", "");
seed_issue_with_facets(root, 2, "needs = [\"ISS-003\"]\n", "", "value = 3.0", "");
seed_issue_with_facets(root, 3, "", "", "value = 5.0", "");
let pg = build(root).unwrap();
let lev_1 = pg.leverage[&key("ISS", 1)];
let lev_2 = pg.leverage[&key("ISS", 2)];
let lev_3 = pg.leverage[&key("ISS", 3)];
assert!((lev_1 - 0.0).abs() < 1e-9, "ISS-001 has no dependents");
assert!((lev_2 - 1.0).abs() < 1e-9, "ISS-002 gets 0.5 * ISS-001");
assert!(
(lev_3 - 2.0).abs() < 1e-9,
"ISS-003 gets 0.5 * (ISS-002+l2)"
);
}
#[test]
fn leverage_diamond_double_counts_shared_leaf() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(
root,
1,
"needs = [\"ISS-002\", \"ISS-003\"]\n",
"",
"value = 10.0",
"",
);
seed_issue_with_facets(root, 2, "needs = [\"ISS-004\"]\n", "", "value = 1.0", "");
seed_issue_with_facets(root, 3, "needs = [\"ISS-004\"]\n", "", "value = 1.0", "");
seed_issue_with_facets(root, 4, "", "", "value = 5.0", "");
let pg = build(root).unwrap();
let lev_1 = pg.leverage[&key("ISS", 1)];
let lev_2 = pg.leverage[&key("ISS", 2)];
let lev_3 = pg.leverage[&key("ISS", 3)];
let lev_4 = pg.leverage[&key("ISS", 4)];
assert!((lev_1 - 0.0).abs() < 1e-9);
assert!((lev_2 - 5.0).abs() < 1e-9);
assert!((lev_3 - 5.0).abs() < 1e-9);
assert!(
(lev_4 - 6.0).abs() < 1e-9,
"D double-counted through both paths"
);
}
#[test]
fn ref_optionality_is_one_hop_no_transitive_accumulation() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "slices = [\"ISS-001\"]\n");
seed_issue_with_facets(root, 1, "slices = [\"ISS-002\"]\n", "", "value = 5.0", "");
seed_issue_with_facets(root, 2, "", "", "value = 3.0", "");
let pg = build(root).unwrap();
let opt_iss2 = pg.optionality[&key("ISS", 2)];
assert!(
(opt_iss2 - 5.0).abs() < 1e-9,
"ISS-002 gets optionality from ISS-001"
);
let opt_iss1 = pg.optionality[&key("ISS", 1)];
assert!(
(opt_iss1 - 0.0).abs() < 1e-9,
"ISS-001 has no valued referencers"
);
assert!(
(pg.optionality[&key("SL", 1)] - 0.0).abs() < 1e-9,
"SL-001 has no referencers"
);
}
#[test]
fn equal_scores_tiebreak_id_asc() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "", "value = 10.0", "");
seed_issue_with_facets(root, 2, "", "", "value = 10.0", "");
let pg = build(root).unwrap();
let s1 = pg.score[&key("ISS", 1)];
let s2 = pg.score[&key("ISS", 2)];
assert!((s1 - s2).abs() < 1e-9, "equal bases yield equal scores");
let keys: Vec<_> = pg.score.keys().collect();
assert!(keys[0] < keys[1], "BTreeMap orders by id asc");
}
#[test]
fn near_max_coefficients_produce_no_nan_or_inf() {
let dir = tmp();
let root = dir.path();
let max_val = config::COEFF_MAX;
write(
root,
".doctrine/doctrine.toml",
&format!(
"[priority]\ncoefficients = {{ value = {max_val}, risk = {max_val} }}\n\
consequence = {{ dep_coeff = 1.0, ref_coeff = {max_val} }}\n"
),
);
seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 1e6", "");
seed_issue_with_facets(
root,
2,
"",
"",
"value = 1e6",
"likelihood = \"critical\"\nimpact = \"critical\"",
);
let pg = build(root).unwrap();
for (_k, &s) in &pg.score {
assert!(s.is_finite(), "score should be finite, got {s}");
}
for (_k, &lev) in &pg.leverage {
assert!(lev.is_finite(), "leverage should be finite, got {lev}");
}
for (_k, &opt) in &pg.optionality {
assert!(opt.is_finite(), "optionality should be finite, got {opt}");
}
}
#[test]
fn self_loop_yields_finite_leverage() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "needs = [\"ISS-001\"]\n", "", "value = 5.0", "");
let pg = build(root).unwrap();
let lev = pg.leverage[&key("ISS", 1)];
assert!(lev.is_finite(), "self-loop leverage should be finite");
}
#[test]
fn multi_member_scc_with_external_dependent() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 1.0", "");
seed_issue_with_facets(root, 2, "needs = [\"ISS-001\"]\n", "", "value = 1.0", "");
seed_issue_with_facets(root, 3, "needs = [\"ISS-002\"]\n", "", "value = 10.0", "");
let pg = build(root).unwrap();
let lev_a = pg.leverage[&key("ISS", 1)];
let lev_b = pg.leverage[&key("ISS", 2)];
let lev_c = pg.leverage[&key("ISS", 3)];
assert!(lev_c == 0.0, "C has no dependents");
assert!(
(lev_a - lev_b).abs() < 1e-9,
"A and B report the same component leverage"
);
assert!((lev_a - 5.0).abs() < 1e-9, "component leverage = 0.5 * 10");
assert!(lev_a.is_finite(), "leverage should be finite");
}
#[test]
fn scc_leverage_uses_component_topo_order_under_seq_perturbation() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 0.0", ""); seed_issue_with_facets(
root,
2,
"needs = [\"ISS-001\"]\nafter = [{ to = \"ISS-003\", rank = 0 }]\n",
"",
"value = 0.0",
"",
); seed_issue_with_facets(root, 3, "needs = [\"ISS-001\"]\n", "", "value = 2.0", ""); seed_issue_with_facets(root, 4, "needs = [\"ISS-003\"]\n", "", "value = 8.0", ""); let pg = build(root).unwrap();
let lev_a = pg.leverage[&key("ISS", 1)];
let lev_b = pg.leverage[&key("ISS", 2)];
let lev_d = pg.leverage[&key("ISS", 3)];
let lev_e = pg.leverage[&key("ISS", 4)];
assert!((lev_e - 0.0).abs() < 1e-9, "E has no dependents");
assert!((lev_d - 4.0).abs() < 1e-9, "D = 0.5 * base(E)");
assert!(
(lev_a - lev_b).abs() < 1e-9,
"A and B share component leverage"
);
assert!(
(lev_a - 3.0).abs() < 1e-9,
"{{A,B}} picks up D's RESOLVED leverage: 0.5*(2+4)=3, not 0.5*2=1"
);
}
#[test]
fn scc_external_dependent_counted_once_per_component() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 0.0", ""); seed_issue_with_facets(root, 2, "needs = [\"ISS-001\"]\n", "", "value = 0.0", ""); seed_issue_with_facets(
root,
3,
"needs = [\"ISS-001\", \"ISS-002\"]\n",
"",
"value = 10.0",
"",
); let pg = build(root).unwrap();
let lev_a = pg.leverage[&key("ISS", 1)];
let lev_b = pg.leverage[&key("ISS", 2)];
let lev_d = pg.leverage[&key("ISS", 3)];
assert!((lev_d - 0.0).abs() < 1e-9, "D has no dependents");
assert!(
(lev_a - lev_b).abs() < 1e-9,
"A and B share component leverage"
);
assert!(
(lev_a - 5.0).abs() < 1e-9,
"D counted once per component: 0.5*10=5, not 0.5*20=10"
);
}
fn seed_issue_with_tags(root: &Path, id: u32, tags: &str, value: &str, estimate: &str) {
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
tags = [{tags}]\n\
[estimate]\n{estimate}\n\
[value]\n{value}\n",
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
#[test]
fn base_score_empty_tags_identity() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "value = 10.0", "");
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!((bs.value_dim - 2.0).abs() < 1e-9, "empty tags → identity");
}
#[test]
fn base_score_with_tag_coefficient() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/doctrine.toml",
"[priority]\ntag_coefficients = { \"area:foo\" = 2.0 }\n",
);
seed_issue_with_tags(
root,
1,
"\"area:foo\"",
"value = 10.0",
"lower = 0.0\nupper = 10.0",
);
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!(
(bs.value_dim - 4.0).abs() < 1e-9,
"tag coeff 2.0 doubles value_dim"
);
}
#[test]
fn base_score_multiple_tags() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/doctrine.toml",
"[priority]\ntag_coefficients = { a = 1.5, b = 2.0 }\n",
);
seed_issue_with_tags(
root,
1,
"\"a\", \"b\"",
"value = 6.0",
"lower = 2.0\nupper = 4.0",
);
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!(
(bs.value_dim - 5.0).abs() < 1e-9,
"tag_term 2.5 → value_dim = 5.0"
);
}
#[test]
fn base_score_demoting_tag() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/doctrine.toml",
"[priority]\ntag_coefficients = { wontfix = 0.5 }\n",
);
seed_issue_with_tags(
root,
1,
"\"wontfix\"",
"value = 20.0",
"lower = 0.0\nupper = 10.0",
);
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!(
(bs.value_dim - 2.0).abs() < 1e-9,
"demoting tag halves value_dim"
);
}
#[test]
fn base_score_multi_demote_floors_at_zero() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/doctrine.toml",
"[priority]\ntag_coefficients = { x = 0.0, y = 0.0 }\n",
);
seed_issue_with_tags(
root,
1,
"\"x\", \"y\"",
"value = 10.0",
"lower = 0.0\nupper = 10.0",
);
let pg = build(root).unwrap();
let bs = pg.attrs[&key("ISS", 1)].base_score;
assert!(
(bs.value_dim - 0.0).abs() < 1e-9,
"multi-demote floors at zero, not negative"
);
}
}