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::priority::partition::{self, StatusClass};
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 }
}
}
const EPSILON: f64 = 1e-12;
fn floor_eps(x: f64) -> f64 {
if x < EPSILON { EPSILON } else { x }
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CostCtx {
pub(crate) absent: f64,
}
fn est_cost(bounds: Option<(f64, f64)>, ctx: CostCtx, ec: &config::EstimateCost) -> f64 {
match bounds {
Some((lower, upper)) => floor_eps(lower + ec.skew * (upper - lower)),
None => ctx.absent,
}
}
pub(crate) const DEFAULT_VALUE: f64 = 1.0;
fn effective_raw_value(kind: &entity::Kind, f: &EntityFacets) -> Option<f64> {
f.value
.as_ref()
.map(|v| v.value)
.or_else(|| crate::kinds::is_value_bearing(kind.prefix).then_some(DEFAULT_VALUE))
}
fn base_score(
f: &EntityFacets,
kind: &entity::Kind,
cfg: &config::PriorityConfig,
ctx: CostCtx,
) -> BaseScore {
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 = match effective_raw_value(kind, f) {
Some(v) => {
let cost = est_cost(
f.estimate.as_ref().map(|e| (e.lower, e.upper)),
ctx,
&cfg.estimate,
);
let kw = cfg.kind_weight(kind.prefix);
cfg.coefficients.value * v * kw * tag_term / cost
}
None => 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) facets: EntityFacets,
}
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::Fulfils,
RelationLabel::Related,
RelationLabel::Reviews,
RelationLabel::OwningSlice,
];
const CONSEQUENCE_LABELS: &[RelationLabel] = &[
RelationLabel::References,
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> {
build_from_with_cfg(scanned, root, &config::load(root))
}
pub(crate) fn build_from_with_cfg(
scanned: &[relation_graph::ScannedEntity],
root: &std::path::Path,
cfg: &config::PriorityConfig,
) -> anyhow::Result<PriorityGraph> {
let max_upper = scanned
.iter()
.filter(|entity| {
partition::status_class(entity.kind, entity.status.as_deref()) != StatusClass::Terminal
})
.filter_map(|entity| entity.estimate.as_ref().map(|e| e.upper))
.max_by(f64::total_cmp);
let absent = match max_upper {
Some(mu) => mu + cfg.estimate.margin,
None => 1.0,
};
let ctx = CostCtx { absent };
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,
ctx,
);
(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,
facets: EntityFacets {
estimate: entity.estimate.clone(),
value: entity.value.clone(),
risk: entity.risk.clone(),
tags: entity.tags.clone(),
},
},
);
}
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 value_dim_of = |nid: cordage::NodeId| -> f64 {
ek(nid)
.and_then(|k| attrs.get(&k))
.map_or(0.0, |a| a.base_score.value_dim)
};
let risk_dim_of = |nid: cordage::NodeId| -> f64 {
ek(nid)
.and_then(|k| attrs.get(&k))
.map_or(0.0, |a| a.base_score.risk_dim)
};
let raw_value_of = |nid: cordage::NodeId| -> f64 {
ek(nid)
.and_then(|k| attrs.get(&k))
.and_then(|a| effective_raw_value(a.kind, &a.facets))
.unwrap_or(0.0)
};
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 fulfils_ov = ref_by_label.get(&RelationLabel::Fulfils).copied();
let mut burndown_by_node: BTreeMap<cordage::NodeId, f64> = BTreeMap::new();
if let Some(ov) = fulfils_ov {
for nid in graph.ordered() {
let raw_val = raw_value_of(nid);
if raw_val <= 0.0 {
burndown_by_node.insert(nid, 0.0);
continue;
}
let mut delivered = 0.0f64;
for (src, _) in graph.in_edges(ov, nid) {
let Some(src_key) = ek(src) else { continue };
let Some(src_attr) = attrs.get(&src_key) else {
continue;
};
let gate = match src_attr.status.as_deref() {
Some("started" | "audit" | "reconcile" | "done") => 1.0,
_ => 0.0,
};
if gate > 0.0 {
delivered += gate * raw_value_of(src);
}
}
let r = (delivered / raw_val).clamp(0.0, 1.0);
let burn = value_dim_of(nid) * (1.0 - r);
let burn = if burn.is_finite() { burn } else { 0.0 };
burndown_by_node.insert(nid, burn);
}
}
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 burn = burndown_by_node
.get(&nid)
.copied()
.unwrap_or(value_dim_of(nid));
let sc = risk_dim_of(nid) + lev + opt + burn;
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 25/6.5) mints before ISS-003 (base 15/6.5)"
);
assert!(
n3 < n1,
"ISS-003 (base 15/6.5) mints before ISS-001 (base 5/6.5)"
);
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 25/6.5≈3.846) mints before the heavily-referenced ISS-001 (base 0) — mint is base-only (I3)"
);
assert!((pg.score.get(&key("ISS", 1)).copied().unwrap_or(0.0) - 1.0 / 11.0).abs() < 1e-9);
}
#[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();
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[estimate]\nlower = 0.0\nupper = 10.0\n\
[value]\nvalue = 25.0\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-005\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "value = 25.0", "");
seed_issue(root, 2, "open", "", "");
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("REQ", 5)).copied().unwrap_or(0.0) - 25.0 / 6.5).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),
1.0,
"free-text drift target: valueless item score = 1.0 (default)"
);
}
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 - 10.0 / 5.9).abs() < 1e-9,
"value_dim should be 10/5.9"
);
assert!((bs.risk_dim - 24.0).abs() < 1e-9, "risk_dim should be 24.0");
assert!(
(bs.total() - (10.0 / 5.9 + 24.0)).abs() < 1e-9,
"total should be 10/5.9 + 24"
);
}
#[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 / 1.3).abs() < 1e-9,
"value_dim should be 5.0/1.3"
);
assert!((bs.risk_dim - 0.0).abs() < 1e-9, "risk_dim should be 0");
assert!(
(bs.total() - 5.0 / 1.3).abs() < 1e-9,
"total should be 5.0/1.3"
);
}
#[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 - 1.0).abs() < 1e-9,
"value_dim should be 1.0 (default)"
);
assert!((bs.risk_dim - 4.0).abs() < 1e-9, "risk_dim should be 4.0");
assert!((bs.total() - 5.0).abs() < 1e-9, "total should be 5.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 - 1.0).abs() < 1e-9,
"value_dim should be 1.0 (default)"
);
assert!((bs.risk_dim - 0.0).abs() < 1e-9, "risk_dim should be 0");
assert!((bs.total() - 1.0).abs() < 1e-9, "total should be 1.0");
}
#[test]
fn base_score_bare_item_empty_corpus_fallback_cost_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 base_score_valueless_sl_equals_explicit_value_one() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "", "");
seed_issue_with_facets(root, 2, "", "lower = 0.0\nupper = 10.0", "value = 1.0", "");
let pg = build(root).unwrap();
let bs1 = pg.attrs[&key("ISS", 1)].base_score;
let bs2 = pg.attrs[&key("ISS", 2)].base_score;
let expected = 1.0 / 6.5;
assert!(
(bs1.value_dim - expected).abs() < 1e-9,
"valueless SL value_dim = 1.0/6.5 = {expected}, got {}",
bs1.value_dim
);
assert!(
(bs2.value_dim - expected).abs() < 1e-9,
"explicit value=1.0 SL value_dim = 1.0/6.5 = {expected}, got {}",
bs2.value_dim
);
}
#[test]
fn base_score_valueless_asm_and_rev_value_dim_zero() {
let facets = crate::facet::EntityFacets {
estimate: None,
value: None,
risk: None,
tags: vec![],
};
let asm_kind = crate::integrity::KINDS
.iter()
.find(|k| k.kind.prefix == "ASM")
.map(|k| k.kind)
.expect("ASM in KINDS");
let rev_kind = crate::integrity::KINDS
.iter()
.find(|k| k.kind.prefix == "REV")
.map(|k| k.kind)
.expect("REV in KINDS");
let iss_kind = crate::integrity::KINDS
.iter()
.find(|k| k.kind.prefix == "ISS")
.map(|k| k.kind)
.expect("ISS in KINDS");
assert_eq!(effective_raw_value(asm_kind, &facets), None);
assert_eq!(effective_raw_value(rev_kind, &facets), None);
assert_eq!(
effective_raw_value(iss_kind, &facets),
Some(DEFAULT_VALUE),
"ISS is value-bearing → default"
);
let cfg = config::PriorityConfig::default();
let ctx = CostCtx { absent: 1.0 };
let bs = base_score(&facets, asm_kind, &cfg, ctx);
assert!(
(bs.value_dim - 0.0).abs() < 1e-9,
"ASM value_dim should be 0"
);
let bs = base_score(&facets, rev_kind, &cfg, ctx);
assert!(
(bs.value_dim - 0.0).abs() < 1e-9,
"REV value_dim should be 0"
);
let bs = base_score(&facets, iss_kind, &cfg, ctx);
assert!(
(bs.value_dim - 1.0).abs() < 1e-9,
"ISS value_dim should be 1.0 (default)"
);
}
#[test]
fn base_score_authored_value_preserved_no_clamp() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "value = 0.3", "");
seed_issue_with_facets(root, 2, "", "lower = 0.0\nupper = 10.0", "value = 0.0", "");
let pg = build(root).unwrap();
let bs1 = pg.attrs[&key("ISS", 1)].base_score;
let bs2 = pg.attrs[&key("ISS", 2)].base_score;
assert!(
(bs1.value_dim - 0.3 / 6.5).abs() < 1e-9,
"authored 0.3 should be 0.3/6.5, not clamped to 1.0"
);
assert!(
(bs2.value_dim - 0.0).abs() < 1e-9,
"authored 0.0 stays 0.0 (not defaulted to 1.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, "");
seed_issue_with_facets(root, 1, "", "", "value = 5.0", "");
seed_issue_with_facets(root, 2, "", "", "value = 3.0", "");
let pg = build(root).unwrap();
assert!(
(pg.optionality[&key("ISS", 2)] - 0.0).abs() < 1e-9,
"ISS-002 has no referencers"
);
assert!(
(pg.optionality[&key("ISS", 1)] - 0.0).abs() < 1e-9,
"ISS-001 has no 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 - 10.0 / 6.5).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 - 20.0 / 6.5).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 - 15.0 / 3.3).abs() < 1e-9,
"tag_term 2.5 → value_dim = 15.0/3.3"
);
}
#[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 - 10.0 / 6.5).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"
);
}
#[test]
fn burndown_lowers_score() {
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", "");
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[value]\nvalue = 4.0\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-002\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
let pg = build(root).unwrap();
let s1 = pg.score[&key("ISS", 1)];
let s2 = pg.score[&key("ISS", 2)];
assert!((s1 - 10.0).abs() < 1e-9, "ISS-001 unchanged, got {s1}");
assert!((s2 - 6.0).abs() < 1e-9, "ISS-002 burndown to 6.0, got {s2}");
assert!(
s2 < s1,
"burndown strictly lowers the fulfilled item's score"
);
}
#[test]
fn burndown_lifecycle_gate() {
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", "");
seed_issue_with_facets(root, 3, "", "", "value = 10.0", "");
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"ready\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[value]\nvalue = 4.0\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-001\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
write(
root,
".doctrine/slice/002/slice-002.toml",
"id = 2\nslug = \"s\"\ntitle = \"S\"\nstatus = \"started\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[value]\nvalue = 4.0\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-002\"\n",
);
write(root, ".doctrine/slice/002/slice-002.md", "scope\n");
write(
root,
".doctrine/slice/003/slice-003.toml",
"id = 3\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[value]\nvalue = 4.0\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-003\"\n",
);
write(root, ".doctrine/slice/003/slice-003.md", "scope\n");
let pg = build(root).unwrap();
assert!(
(pg.score[&key("ISS", 1)] - 10.0).abs() < 1e-9,
"ready status burns nothing: Fulfils burndown lifecycle gate"
);
assert!(
(pg.score[&key("ISS", 2)] - 6.0).abs() < 1e-9,
"started status burns fully: Fulfils burndown lifecycle gate"
);
assert!(
(pg.score[&key("ISS", 3)] - 6.0).abs() < 1e-9,
"done (via started) burns fully: Fulfils burndown lifecycle gate"
);
}
#[test]
fn burndown_non_conservation() {
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", "");
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[value]\nvalue = 4.0\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-001\"\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-002\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
let pg = build(root).unwrap();
let s1 = pg.score[&key("ISS", 1)];
let s2 = pg.score[&key("ISS", 2)];
assert!((s1 - 6.0).abs() < 1e-9, "ISS-001 burndown to 6.0, got {s1}");
assert!((s2 - 6.0).abs() < 1e-9, "ISS-002 burndown to 6.0, got {s2}");
assert!(
(s1 - s2).abs() < 1e-9,
"Fulfils burndown is non-conserving across multi-item"
);
}
#[test]
fn originates_from_inert_for_priority() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", "originates_from = [\"SL-001\"]\n");
seed_slice(root, 1, "");
let pg = build(root).unwrap();
assert!(
pg.optionality.get(&key("ISS", 1)).copied().unwrap_or(0.0) == 0.0,
"originates_from is not a CONSEQUENCE_LABELS member → optionality = 0"
);
assert!(
pg.optionality.get(&key("SL", 1)).copied().unwrap_or(0.0) == 0.0,
"SL target of originates_from gets no optionality"
);
assert!(
(pg.score[&key("ISS", 1)] - 1.0).abs() < 1e-9,
"originates_from: valueless item score = 1.0 (default); still no lev/opt change"
);
}
#[test]
fn burndown_exact_value_divergence_trap() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 20.0", "value = 10.0", "");
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[value]\nvalue = 5.0\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-001\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
let pg = build(root).unwrap();
let expected: f64 = 10.0 / 13.0 * 0.5; let got = pg.score[&key("ISS", 1)];
assert!(
(got - expected).abs() < 1e-9,
"Fulfils burndown uses raw_value denominator ({expected}) not value_dim, got {got}"
);
}
#[test]
fn burndown_decomposition() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(root, 1, "", "", "value = 10.0", "");
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[value]\nvalue = 4.0\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-001\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
let pg = build(root).unwrap();
assert!(
pg.optionality[&key("ISS", 1)] == 0.0,
"Fulfils not in CONSEQUENCE_LABELS: optionality from fulfils = 0"
);
let s = pg.score[&key("ISS", 1)];
assert!(
(s - 6.0).abs() < 1e-9,
"Fulfils burndown decomposition: score=6.0, got {s}"
);
let baseline = 10.0;
let delta = baseline - s;
assert!(
(delta - 4.0).abs() < 1e-9,
"decomposition: delta={delta} equals burndown term ONLY (no slices/optionality double-count)"
);
}
#[test]
fn burndown_valueless_fulfilling_slice_delivers_default_value() {
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", "");
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"started\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-002\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
let pg = build(root).unwrap();
let s1 = pg.score[&key("ISS", 1)];
let s2 = pg.score[&key("ISS", 2)];
assert!(
(s1 - 10.0).abs() < 1e-9,
"ISS-001 unfulfilled baseline 10.0, got {s1}"
);
assert!(
(s2 - 9.0).abs() < 1e-9,
"ISS-002 burndown by valueless slice to 9.0, got {s2}"
);
assert!(
s2 < s1,
"valueless fulfilling slice reduces the item's score"
);
assert!(
s2 > 0.0,
"score is positive (delivered > 0 from default value), got {s2}"
);
}
#[test]
fn burndown_non_value_bearing_source_contributes_zero() {
let facets = crate::facet::EntityFacets {
estimate: None,
value: None,
risk: None,
tags: vec![],
};
let rev_kind = crate::integrity::KINDS
.iter()
.find(|k| k.kind.prefix == "REV")
.map(|k| k.kind)
.expect("REV in KINDS");
let asm_kind = crate::integrity::KINDS
.iter()
.find(|k| k.kind.prefix == "ASM")
.map(|k| k.kind)
.expect("ASM in KINDS");
let iss_kind = crate::integrity::KINDS
.iter()
.find(|k| k.kind.prefix == "ISS")
.map(|k| k.kind)
.expect("ISS in KINDS");
assert_eq!(effective_raw_value(rev_kind, &facets), None);
assert_eq!(effective_raw_value(asm_kind, &facets), None);
assert_eq!(effective_raw_value(iss_kind, &facets), Some(DEFAULT_VALUE));
}
#[test]
fn build_from_equals_build_from_with_cfg_over_loaded_config() {
let dir = tmp();
let root = dir.path();
seed_issue_with_facets(
root,
1,
"needs = [\"RSK-001\"]",
"lower = 0.0\nupper = 10.0",
"value = 25.0",
"",
);
seed_issue_with_facets(root, 2, "", "lower = 1.0\nupper = 4.0", "value = 5.0", "");
seed_risk(root, 1, "open", "");
seed_slice(root, 1, "references = [\"REQ-005\"]");
seed_requirement(root, 5);
let scanned =
relation_graph::scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
let via_load = build_from(&scanned, root).unwrap();
let via_cfg = build_from_with_cfg(&scanned, root, &config::load(root)).unwrap();
assert_eq!(via_load.score, via_cfg.score, "score map identical");
assert_eq!(
via_load.leverage, via_cfg.leverage,
"leverage map identical"
);
assert_eq!(
via_load.optionality, via_cfg.optionality,
"optionality map identical"
);
let base = |pg: &PriorityGraph| -> std::collections::BTreeMap<EntityKey, (f64, f64)> {
pg.attrs
.iter()
.map(|(k, a)| (*k, (a.base_score.value_dim, a.base_score.risk_dim)))
.collect()
};
assert_eq!(base(&via_load), base(&via_cfg), "base scores identical");
let order = |pg: &PriorityGraph| -> Vec<EntityKey> {
pg.graph
.ordered()
.iter()
.filter_map(|n| pg.projection.key_of(*n))
.collect()
};
assert_eq!(order(&via_load), order(&via_cfg), "minted order identical");
}
}