use std::collections::BTreeMap;
use cordage::{
Arity, CyclePolicy, Direction, EdgeAttrs, Graph, GraphBuilder, OrderLayer, OrderSpec,
OverlayConfig, OverlayId,
};
use crate::projection::Projection;
use crate::relation::RelationLabel;
use crate::relation_graph::{self, EntityKey};
use crate::{dep_seq, entity, integrity};
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) struct PriorityGraph {
pub(crate) graph: Graph,
pub(crate) projection: Projection<EntityKey>,
pub(crate) attrs: BTreeMap<EntityKey, NodeAttr>,
pub(crate) consequence: BTreeMap<EntityKey, u32>,
pub(crate) dep_overlay: OverlayId,
pub(crate) seq_overlay: OverlayId,
}
const REF_LABELS: &[RelationLabel] = &[
RelationLabel::Specs,
RelationLabel::Requirements,
RelationLabel::Supersedes,
RelationLabel::DescendsFrom,
RelationLabel::Parent,
RelationLabel::Members,
RelationLabel::Interactions,
RelationLabel::Slices,
RelationLabel::Related,
RelationLabel::Reviews,
RelationLabel::OwningSlice,
];
const CONSEQUENCE_LABELS: &[RelationLabel] = &[
RelationLabel::Specs,
RelationLabel::Requirements,
RelationLabel::Slices,
RelationLabel::DescendsFrom,
RelationLabel::Parent,
RelationLabel::Members,
];
fn counts_toward_consequence(label: RelationLabel) -> bool {
CONSEQUENCE_LABELS.contains(&label)
}
pub(crate) fn build(root: &std::path::Path) -> anyhow::Result<PriorityGraph> {
build_from(&relation_graph::scan_entities(root, &mut vec![])?, root)
}
pub(crate) fn build_from(
scanned: &[relation_graph::ScannedEntity],
root: &std::path::Path,
) -> anyhow::Result<PriorityGraph> {
let keys: std::collections::BTreeSet<EntityKey> = scanned.iter().map(|e| e.key).collect();
let mut consequence: BTreeMap<EntityKey, u32> = BTreeMap::new();
for entity in scanned {
for edge in &entity.outbound {
if !counts_toward_consequence(edge.label) {
continue;
}
if let Ok((kref, tid)) = integrity::parse_canonical_ref(&edge.target) {
let target = EntityKey {
prefix: kref.kind.prefix,
id: tid,
};
if keys.contains(&target) {
*consequence.entry(target).or_insert(0) += 1;
}
}
}
}
let mut order: Vec<EntityKey> = scanned.iter().map(|e| e.key).collect();
order.sort_by(|a, b| {
let ca = consequence.get(a).copied().unwrap_or(0);
let cb = consequence.get(b).copied().unwrap_or(0);
cb.cmp(&ca).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 {
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(),
},
);
}
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:?}"
)
})?;
Ok(PriorityGraph {
graph,
projection,
attrs,
consequence,
dep_overlay,
seq_overlay,
})
}
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))
.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, "requirements = [\"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![])
.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 consequence_counts_only_work_lineage_labels() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "");
seed_issue(root, 1, "open", "", "slices = [\"SL-001\"]\n");
seed_review(root, 1, "SL-001", "");
seed_rec(root, 1, "SL-001");
let pg = build(root).unwrap();
assert_eq!(
pg.consequence.get(&key("SL", 1)).copied().unwrap_or(0),
1,
"only the slices edge counts; reviews + owning_slice excluded"
);
}
#[test]
fn lineage_edge_raises_consequence() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "requirements = [\"REQ-005\", \"REQ-006\"]\n");
seed_slice(root, 2, "requirements = [\"REQ-005\"]\n");
seed_requirement(root, 5);
seed_requirement(root, 6);
let pg = build(root).unwrap();
assert_eq!(pg.consequence.get(&key("REQ", 5)).copied(), Some(2));
assert_eq!(pg.consequence.get(&key("REQ", 6)).copied(), Some(1));
assert_eq!(pg.consequence.get(&key("SL", 1)).copied().unwrap_or(0), 0);
}
#[test]
fn mint_order_consequence_desc_then_canonical_asc_and_permutation_invariant() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, "requirements = [\"REQ-006\"]\n");
seed_slice(root, 2, "requirements = [\"REQ-005\", \"REQ-006\"]\n");
seed_requirement(root, 5);
seed_requirement(root, 6);
seed_requirement(root, 7);
let pg = build(root).unwrap();
let n6 = pg.projection.resolve(key("REQ", 6)).unwrap();
let n5 = pg.projection.resolve(key("REQ", 5)).unwrap();
let n7 = pg.projection.resolve(key("REQ", 7)).unwrap();
assert!(n6 < n5, "REQ-006 (consequence 2) mints before REQ-005 (1)");
assert!(n5 < n7, "REQ-005 (1) mints before REQ-007 (0)");
let dir2 = tmp();
let root2 = dir2.path();
seed_requirement(root2, 7);
seed_requirement(root2, 6);
seed_requirement(root2, 5);
seed_slice(root2, 2, "requirements = [\"REQ-006\", \"REQ-005\"]\n");
seed_slice(root2, 1, "requirements = [\"REQ-006\"]\n");
let pg2 = build(root2).unwrap();
assert_eq!(
pg.consequence, pg2.consequence,
"consequence is permutation-invariant"
);
let m6 = pg2.projection.resolve(key("REQ", 6)).unwrap();
let m5 = pg2.projection.resolve(key("REQ", 5)).unwrap();
let m7 = pg2.projection.resolve(key("REQ", 7)).unwrap();
assert!(m6 < m5 && m5 < m7, "mint order is permutation-invariant");
}
#[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_requirement(root, 5);
seed_slice(root, 1, "requirements = [\"REQ-005\"]\n");
seed_slice(root, 2, "");
let pg = build(root).unwrap();
let sl1 = pg.projection.resolve(key("SL", 1)).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.consequence.get(&key("REQ", 5)).copied().unwrap_or(0),
1,
"resolvable consequence ref produces its edge (no phantom dangle)"
);
}
#[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();
assert_eq!(
pg.consequence.get(&key("ISS", 1)).copied().unwrap_or(0),
0,
"free-text drift target produces no edge → no consequence"
);
}
}