use crate::backlog::ItemKind;
use cordage::{
Arity, CyclePolicy, Direction, EdgeAttrs, EvictReason, Graph, GraphBuilder, NodeId, OrderLayer,
OrderSpec, OverlayConfig, OverlayId,
};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ItemId {
kind: ItemKind,
id: u32,
}
impl ItemId {
pub(crate) fn new(kind: ItemKind, id: u32) -> Self {
Self { kind, id }
}
pub(crate) fn render(self) -> String {
self.kind.canonical_id(self.id)
}
}
impl Ord for ItemId {
fn cmp(&self, other: &Self) -> Ordering {
(self.kind.prefix(), self.id).cmp(&(other.kind.prefix(), other.id))
}
}
impl PartialOrd for ItemId {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OrderInput {
item: ItemId,
created: String,
exposure: u8,
needs: Vec<ItemId>,
after: Vec<(ItemId, i32)>,
}
impl OrderInput {
pub(crate) fn new(
item: ItemId,
created: String,
exposure: u8,
needs: Vec<ItemId>,
after: Vec<(ItemId, i32)>,
) -> Self {
Self {
item,
created,
exposure,
needs,
after,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Override {
from: ItemId,
to: ItemId,
reason: OverrideReason,
}
impl Override {
pub(crate) fn from(self) -> ItemId {
self.from
}
pub(crate) fn to(self) -> ItemId {
self.to
}
pub(crate) fn reason(self) -> OverrideReason {
self.reason
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OverrideReason {
SoftCycleEvicted,
Contradicted,
Dangling,
}
#[derive(Debug)]
pub(crate) struct BacklogOrder {
graph: Graph,
by_node: BTreeMap<NodeId, ItemId>,
needs_overlay: OverlayId,
after_overlay: OverlayId,
dangling: Vec<Override>,
}
impl BacklogOrder {
pub(crate) fn build(inputs: &[OrderInput]) -> anyhow::Result<Self> {
let mut builder = GraphBuilder::new();
let needs_overlay =
builder.overlay(OverlayConfig::new(CyclePolicy::Reject, Arity::Unbounded));
let after_overlay =
builder.overlay(OverlayConfig::new(CyclePolicy::Evict, Arity::Unbounded));
let mut ordered_inputs: Vec<&OrderInput> = inputs.iter().collect();
ordered_inputs.sort_by(|a, b| {
b.exposure
.cmp(&a.exposure)
.then_with(|| a.created.cmp(&b.created))
.then_with(|| a.item.cmp(&b.item))
});
let mut by_item: BTreeMap<ItemId, NodeId> = BTreeMap::new();
let mut by_node: BTreeMap<NodeId, ItemId> = BTreeMap::new();
for input in &ordered_inputs {
let node = builder.node();
by_item.insert(input.item, node);
by_node.insert(node, input.item);
}
let mut dangling: Vec<Override> = Vec::new();
for input in &ordered_inputs {
let Some(&src) = by_item.get(&input.item) else {
continue;
};
for dep in &input.needs {
match by_item.get(dep) {
Some(&prereq) => {
builder.edge(needs_overlay, prereq, src, EdgeAttrs::new(0, 0));
}
None => dangling.push(Override {
from: *dep,
to: input.item,
reason: OverrideReason::Dangling,
}),
}
}
for (idx, (to, rank)) in input.after.iter().enumerate() {
match by_item.get(to) {
Some(&prereq) => {
let age = u64::try_from(idx).map_err(|e| {
anyhow::anyhow!("backlog_order: after-edge index overflows u64: {e}")
})?;
builder.edge(after_overlay, prereq, src, EdgeAttrs::new(*rank, age));
}
None => dangling.push(Override {
from: *to,
to: input.item,
reason: OverrideReason::Dangling,
}),
}
}
}
builder.order_spec(OrderSpec::new(vec![
OrderLayer::new(needs_overlay, Direction::Along),
OrderLayer::new(after_overlay, Direction::Along),
]));
let graph = builder.build().map_err(|e| {
anyhow::anyhow!(
"backlog_order: cordage rejected well-formed adapter input (internal bug): {e:?}"
)
})?;
Ok(Self {
graph,
by_node,
needs_overlay,
after_overlay,
dangling,
})
}
pub(crate) fn ordered(&self) -> Vec<ItemId> {
self.graph
.ordered()
.iter()
.filter_map(|node| self.by_node.get(node).copied())
.collect()
}
pub(crate) fn dep_cycles(&self) -> Vec<BTreeSet<ItemId>> {
self.graph
.provenance()
.cycles()
.iter()
.filter(|cycle| cycle.overlay() == self.needs_overlay)
.map(|cycle| {
cycle
.nodes()
.iter()
.filter_map(|node| self.by_node.get(node).copied())
.collect()
})
.collect()
}
pub(crate) fn overrides(&self) -> Vec<Override> {
let mut out: Vec<Override> = self
.graph
.provenance()
.evictions()
.iter()
.filter(|evicted| evicted.overlay() == self.after_overlay)
.filter_map(|evicted| {
let from = self.by_node.get(&evicted.edge().src()).copied()?;
let to = self.by_node.get(&evicted.edge().dst()).copied()?;
let reason = match evicted.reason() {
EvictReason::IntraOverlayCycle => OverrideReason::SoftCycleEvicted,
EvictReason::UnionCycleVsLayer => OverrideReason::Contradicted,
EvictReason::ArityViolation => return None,
};
Some(Override { from, to, reason })
})
.collect();
out.extend(self.dangling.iter().copied());
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rsk(id: u32) -> ItemId {
ItemId {
kind: ItemKind::Risk,
id,
}
}
fn iss(id: u32) -> ItemId {
ItemId {
kind: ItemKind::Issue,
id,
}
}
fn inp(
item: ItemId,
created: &str,
exposure: u8,
needs: Vec<ItemId>,
after: Vec<(ItemId, i32)>,
) -> OrderInput {
OrderInput::new(item, created.to_string(), exposure, needs, after)
}
fn pos(order: &[ItemId], item: ItemId) -> usize {
order.iter().position(|x| *x == item).unwrap()
}
#[test]
fn item_id_renders_canonical_ref() {
assert_eq!(rsk(2).render(), "RSK-002");
assert_eq!(iss(7).render(), "ISS-007");
assert_eq!(ItemId::new(ItemKind::Chore, 11).render(), "CHR-011");
}
#[test]
fn needs_orders_the_prerequisite_first() {
let a = rsk(1);
let b = rsk(2);
let inputs = vec![
inp(a, "2026-06-01", 0, vec![b], vec![]), inp(b, "2026-06-01", 0, vec![], vec![]),
];
let order = BacklogOrder::build(&inputs).unwrap().ordered();
assert!(
pos(&order, b) < pos(&order, a),
"B (prerequisite) precedes A; got {order:?}"
);
}
#[test]
fn after_orders_the_predecessor_first() {
let a = rsk(1);
let b = rsk(2);
let inputs = vec![
inp(a, "2026-06-01", 0, vec![], vec![(b, 0)]), inp(b, "2026-06-01", 0, vec![], vec![]),
];
let order = BacklogOrder::build(&inputs).unwrap().ordered();
assert!(
pos(&order, b) < pos(&order, a),
"B (predecessor) precedes A under the after flip; got {order:?}"
);
}
#[test]
fn order_and_overrides_are_identical_under_input_permutation() {
let a = rsk(1);
let b = rsk(2);
let c = iss(7);
let d = iss(8);
let forward = vec![
inp(a, "2026-06-02", 4, vec![b], vec![]), inp(b, "2026-06-01", 0, vec![], vec![]),
inp(c, "2026-06-03", 8, vec![d], vec![]), inp(d, "2026-06-04", 8, vec![], vec![(c, 0)]), ];
let reverse: Vec<OrderInput> = forward.iter().rev().cloned().collect();
let fwd = BacklogOrder::build(&forward).unwrap();
let rev = BacklogOrder::build(&reverse).unwrap();
assert_eq!(fwd.ordered(), rev.ordered());
assert!(!fwd.overrides().is_empty(), "fixture must evict an edge");
assert_eq!(fwd.overrides(), rev.overrides());
}
#[test]
fn no_edges_falls_to_allocation_order() {
let hi = rsk(1); let mid = iss(7); let lo = rsk(3); let inputs = vec![
inp(lo, "2026-06-05", 0, vec![], vec![]),
inp(mid, "2026-06-01", 0, vec![], vec![]),
inp(hi, "2026-06-09", 9, vec![], vec![]),
];
let order = BacklogOrder::build(&inputs).unwrap().ordered();
assert_eq!(order, vec![hi, mid, lo]);
}
#[test]
fn dependency_cycle_is_named_in_item_ids() {
let a = rsk(1);
let b = rsk(2);
let inputs = vec![
inp(a, "2026-06-01", 0, vec![b], vec![]),
inp(b, "2026-06-01", 0, vec![a], vec![]),
];
let cycles = BacklogOrder::build(&inputs).unwrap().dep_cycles();
assert_eq!(cycles, vec![BTreeSet::from([a, b])]);
}
#[test]
fn after_contradicting_a_need_is_overridden() {
let a = rsk(1);
let b = rsk(2);
let inputs = vec![
inp(a, "2026-06-01", 0, vec![b], vec![]), inp(b, "2026-06-01", 0, vec![], vec![(a, 0)]), ];
let built = BacklogOrder::build(&inputs).unwrap();
let order = built.ordered();
assert!(pos(&order, b) < pos(&order, a), "need wins; got {order:?}");
let overrides = built.overrides();
assert!(
overrides.iter().any(|o| o.from() == a
&& o.to() == b
&& o.reason() == OverrideReason::Contradicted),
"the after edge A→B is overridden as Contradicted; got {overrides:?}"
);
}
#[test]
fn soft_after_cycle_is_evicted_not_fatal() {
let x = rsk(1);
let y = rsk(2);
let inputs = vec![
inp(x, "2026-06-01", 0, vec![], vec![(y, 0)]), inp(y, "2026-06-01", 0, vec![], vec![(x, 0)]), ];
let built = BacklogOrder::build(&inputs).unwrap();
assert_eq!(built.ordered().len(), 2, "order still produced");
let overrides = built.overrides();
assert_eq!(overrides.len(), 1, "one edge evicted; got {overrides:?}");
assert_eq!(overrides[0].reason(), OverrideReason::SoftCycleEvicted);
let (from, to) = (overrides[0].from(), overrides[0].to());
assert!(
(from == x && to == y) || (from == y && to == x),
"the evicted edge is between X and Y"
);
}
#[test]
fn lower_rank_after_edge_is_the_one_evicted_in_a_soft_cycle() {
let x = rsk(1);
let y = rsk(2);
let inputs = vec![
inp(x, "2026-06-01", 0, vec![], vec![(y, 5)]),
inp(y, "2026-06-01", 0, vec![], vec![(x, 1)]),
];
let built = BacklogOrder::build(&inputs).unwrap();
let overrides = built.overrides();
assert_eq!(overrides.len(), 1, "one edge evicted; got {overrides:?}");
assert_eq!(overrides[0].reason(), OverrideReason::SoftCycleEvicted);
assert_eq!(
(overrides[0].from(), overrides[0].to()),
(x, y),
"the lower-rank edge X→Y is evicted; got {overrides:?}"
);
let order = built.ordered();
assert!(
pos(&order, y) < pos(&order, x),
"the surviving high-rank Y→X edge keeps Y before X; got {order:?}"
);
}
#[test]
fn lower_age_after_edge_is_the_one_evicted_in_an_equal_rank_soft_cycle() {
let x = rsk(1);
let y = rsk(2);
let z = rsk(3);
let inputs = vec![
inp(x, "2026-06-01", 0, vec![], vec![(y, 0)]), inp(y, "2026-06-01", 0, vec![], vec![(z, 0), (x, 0)]),
inp(z, "2026-06-01", 0, vec![], vec![]),
];
let built = BacklogOrder::build(&inputs).unwrap();
let overrides = built.overrides();
assert_eq!(overrides.len(), 1, "one edge evicted; got {overrides:?}");
assert_eq!(overrides[0].reason(), OverrideReason::SoftCycleEvicted);
assert_eq!(
(overrides[0].from(), overrides[0].to()),
(y, x),
"the lower-age edge Y→X is evicted; got {overrides:?}"
);
}
#[test]
fn dangling_edge_endpoint_is_dropped_and_recorded() {
let a = rsk(1);
let ghost = rsk(9);
let inputs = vec![inp(a, "2026-06-01", 0, vec![ghost], vec![])];
let built = BacklogOrder::build(&inputs).unwrap();
assert_eq!(built.ordered(), vec![a], "the ghost is not a node");
let overrides = built.overrides();
assert_eq!(overrides.len(), 1);
assert_eq!(overrides[0].reason(), OverrideReason::Dangling);
assert_eq!(overrides[0].from(), ghost);
assert_eq!(overrides[0].to(), a);
}
#[test]
fn exposure_breaks_ties_within_a_level() {
let hi = rsk(1);
let lo = iss(7);
let inputs = vec![
inp(lo, "2026-06-01", 0, vec![], vec![]),
inp(hi, "2026-06-01", 12, vec![], vec![]),
];
let order = BacklogOrder::build(&inputs).unwrap().ordered();
assert!(
pos(&order, hi) < pos(&order, lo),
"high exposure precedes baseline at equal level; got {order:?}"
);
}
#[test]
fn independent_baseline_is_not_buried_behind_a_blocked_high_exposure_item() {
let top = rsk(1);
let mid = rsk(2);
let bottom = rsk(3);
let free = iss(7);
let inputs = vec![
inp(top, "2026-06-01", 16, vec![mid], vec![]),
inp(mid, "2026-06-01", 0, vec![bottom], vec![]),
inp(bottom, "2026-06-01", 0, vec![], vec![]),
inp(free, "2026-06-01", 0, vec![], vec![]),
];
let order = BacklogOrder::build(&inputs).unwrap().ordered();
assert!(
pos(&order, free) < pos(&order, top),
"independent baseline not buried; got {order:?}"
);
assert!(pos(&order, bottom) < pos(&order, mid) && pos(&order, mid) < pos(&order, top));
}
#[test]
fn an_after_edge_beats_exposure() {
let hi = rsk(1);
let lo = rsk(2);
let inputs = vec![
inp(hi, "2026-06-01", 16, vec![], vec![(lo, 0)]), inp(lo, "2026-06-01", 0, vec![], vec![]),
];
let order = BacklogOrder::build(&inputs).unwrap().ordered();
assert!(
pos(&order, lo) < pos(&order, hi),
"the after edge (lo→hi) beats hi's exposure; got {order:?}"
);
}
#[test]
fn no_pub_crate_signature_leaks_a_cordage_id() {
let src = include_str!("backlog_order.rs");
for (idx, line) in src.lines().enumerate() {
if line.trim_start().starts_with("pub(crate)") {
assert!(
!line.contains("NodeId") && !line.contains("OverlayId"),
"line {}: pub(crate) signature leaks an opaque cordage id: {line}",
idx + 1
);
}
}
}
}