use std::collections::btree_map::Entry;
use std::collections::{BTreeMap, BTreeSet};
use super::compile::{CompilationStatus, QuarantineReason};
use super::{ComparisonSession, DOMAIN_PRIORITY, Judgement, RaterKind, RowForm};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResolutionStatus {
Active,
Superseded { by: String },
Tombstoned,
InertLens,
InertDomain,
InertLifecycle,
Malformed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RowState {
pub resolution: ResolutionStatus,
pub compilation: Option<CompilationStatus>,
}
impl RowState {
pub(crate) fn new(
resolution: ResolutionStatus,
compilation: Option<CompilationStatus>,
) -> Self {
Self {
resolution,
compilation,
}
}
pub(crate) fn display_token(&self) -> String {
match &self.resolution {
ResolutionStatus::Superseded { by } => format!("superseded→{by}"),
ResolutionStatus::Tombstoned => "tombstoned".to_string(),
ResolutionStatus::InertLens => "inert(lens)".to_string(),
ResolutionStatus::InertDomain => "inert(domain)".to_string(),
ResolutionStatus::InertLifecycle => "inert(lifecycle)".to_string(),
ResolutionStatus::Malformed => "malformed".to_string(),
ResolutionStatus::Active => match &self.compilation {
Some(CompilationStatus::NoConstraint) => "no-constraint".to_string(),
Some(CompilationStatus::Quarantined(QuarantineReason::PreferenceCycle {
..
})) => "quarantined(cycle)".to_string(),
Some(CompilationStatus::Quarantined(QuarantineReason::AnchorConflict {
..
})) => "quarantined(anchors)".to_string(),
Some(CompilationStatus::Constraining) | None => "active".to_string(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MalformedSupersession {
pub cycle: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UnknownSupersedesTarget {
pub row: String,
pub target: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EntityLifecycle {
Terminal,
Superseded { by: String },
}
pub(crate) type StatusMap = BTreeMap<String, EntityLifecycle>;
#[derive(Debug, PartialEq)]
pub(crate) struct Resolution<'a> {
pub rows: Vec<(&'a Judgement, ResolutionStatus)>,
pub unknown_supersedes: Vec<UnknownSupersedesTarget>,
pub malformed: Vec<MalformedSupersession>,
}
#[derive(Debug)]
struct RowMeta<'a> {
judgement: &'a Judgement,
session_uid: &'a str,
}
pub(crate) fn resolve<'a>(
sessions: &'a [ComparisonSession],
statuses: &StatusMap,
) -> anyhow::Result<Resolution<'a>> {
let mut ordered: Vec<&'a ComparisonSession> = sessions.iter().collect();
ordered.sort_by(|a, b| a.session.uid.cmp(&b.session.uid));
let rows = collect_rows(&ordered)?;
let tombstoned = tombstoned_targets(&ordered);
let (on_cycle, malformed) = supersession_cycles(&rows, &tombstoned);
let (superseded_by, unknown_supersedes) = supersession_effects(&rows, &tombstoned, &on_cycle);
let superseded_r3 = implicit_revisions(&rows, &tombstoned);
let mut out: Vec<(&'a Judgement, &'a str, ResolutionStatus)> = Vec::new();
for (&uid, meta) in &rows {
let j = meta.judgement;
let status = if tombstoned.contains(&uid) {
ResolutionStatus::Tombstoned
} else if on_cycle.contains(&uid) {
ResolutionStatus::Malformed
} else if let Some(&by) = superseded_by.get(&uid) {
ResolutionStatus::Superseded { by: by.to_string() }
} else if let Some(&by) = superseded_r3.get(&uid) {
ResolutionStatus::Superseded { by: by.to_string() }
} else if j.domain == DOMAIN_PRIORITY {
ResolutionStatus::InertDomain
} else if j.lens.is_some() {
ResolutionStatus::InertLens
} else if entity_superseded(j, statuses) {
ResolutionStatus::InertLifecycle
} else {
ResolutionStatus::Active
};
out.push((j, meta.session_uid, status));
}
out.sort_by(|a, b| {
a.0.ordering_date()
.cmp(b.0.ordering_date())
.then_with(|| a.1.cmp(b.1))
.then_with(|| a.0.seq.cmp(&b.0.seq))
});
let ordered_rows = out.into_iter().map(|(j, _sid, st)| (j, st)).collect();
Ok(Resolution {
rows: ordered_rows,
unknown_supersedes,
malformed,
})
}
fn collect_rows<'a>(
ordered: &[&'a ComparisonSession],
) -> anyhow::Result<BTreeMap<&'a str, RowMeta<'a>>> {
let mut by_uid: BTreeMap<&'a str, RowMeta<'a>> = BTreeMap::new();
for sess in ordered {
for j in &sess.judgements {
match by_uid.entry(j.uid.as_str()) {
Entry::Occupied(existing) => {
if existing.get().judgement != j {
anyhow::bail!(
"comparison row uid `{}` appears with differing content across \
session files — resolve the conflict at source",
j.uid
);
}
}
Entry::Vacant(slot) => {
slot.insert(RowMeta {
judgement: j,
session_uid: sess.session.uid.as_str(),
});
}
}
}
}
Ok(by_uid)
}
fn tombstoned_targets<'a>(ordered: &[&'a ComparisonSession]) -> BTreeSet<&'a str> {
let mut out = BTreeSet::new();
for sess in ordered {
for t in &sess.tombstones {
out.insert(t.target.as_str());
}
}
out
}
fn supersession_cycles<'a>(
rows: &BTreeMap<&'a str, RowMeta<'a>>,
tombstoned: &BTreeSet<&'a str>,
) -> (BTreeSet<&'a str>, Vec<MalformedSupersession>) {
let mut edge: BTreeMap<&'a str, &'a str> = BTreeMap::new();
for (&uid, meta) in rows {
if tombstoned.contains(&uid) {
continue; }
if let Some(target) = meta.judgement.supersedes.as_deref()
&& !tombstoned.contains(&target)
&& rows.contains_key(target)
{
edge.insert(uid, target);
}
}
let mut state: BTreeMap<&'a str, u8> = BTreeMap::new();
let mut on_cycle: BTreeSet<&'a str> = BTreeSet::new();
for &start in edge.keys() {
if state.get(&start).copied().unwrap_or(0) != 0 {
continue;
}
let mut chain: Vec<&'a str> = Vec::new();
let mut cur = start;
loop {
match state.get(&cur).copied().unwrap_or(0) {
0 => {
state.insert(cur, 1);
chain.push(cur);
match edge.get(&cur) {
Some(&next) => cur = next,
None => break,
}
}
1 => {
let mut c = cur;
loop {
on_cycle.insert(c);
match edge.get(&c) {
Some(&n) if n != cur => c = n,
_ => break,
}
}
break;
}
_ => break, }
}
for n in chain {
state.insert(n, 2);
}
}
let mut findings: Vec<MalformedSupersession> = Vec::new();
let mut seen: BTreeSet<&'a str> = BTreeSet::new();
for &node in &on_cycle {
if seen.contains(&node) {
continue;
}
let mut members: BTreeSet<String> = BTreeSet::new();
let mut c = node;
loop {
if !seen.insert(c) {
break;
}
members.insert(c.to_string());
match edge.get(&c) {
Some(&next) if on_cycle.contains(&next) => c = next,
_ => break,
}
}
findings.push(MalformedSupersession {
cycle: members.into_iter().collect(),
});
}
(on_cycle, findings)
}
fn supersession_effects<'a>(
rows: &BTreeMap<&'a str, RowMeta<'a>>,
tombstoned: &BTreeSet<&'a str>,
on_cycle: &BTreeSet<&'a str>,
) -> (BTreeMap<&'a str, &'a str>, Vec<UnknownSupersedesTarget>) {
let mut superseded_by: BTreeMap<&'a str, &'a str> = BTreeMap::new();
let mut unknown: Vec<UnknownSupersedesTarget> = Vec::new();
for (&uid, meta) in rows {
if tombstoned.contains(&uid) {
continue; }
let Some(target) = meta.judgement.supersedes.as_deref() else {
continue;
};
if !rows.contains_key(target) {
unknown.push(UnknownSupersedesTarget {
row: uid.to_string(),
target: target.to_string(),
});
continue;
}
if on_cycle.contains(&uid) {
continue; }
superseded_by.entry(target).or_insert(uid);
}
(superseded_by, unknown)
}
fn implicit_revisions<'a>(
rows: &BTreeMap<&'a str, RowMeta<'a>>,
tombstoned: &BTreeSet<&'a str>,
) -> BTreeMap<&'a str, &'a str> {
let mut groups: BTreeMap<IdentityKey, Vec<(&'a str, u32)>> = BTreeMap::new();
for (&uid, meta) in rows {
if tombstoned.contains(&uid) {
continue;
}
let key = identity_key(meta.session_uid, meta.judgement);
groups
.entry(key)
.or_default()
.push((uid, meta.judgement.seq));
}
let mut superseded: BTreeMap<&'a str, &'a str> = BTreeMap::new();
for members in groups.values() {
if members.len() < 2 {
continue;
}
let Some(&(winner, _)) = members
.iter()
.max_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(b.0)))
else {
continue;
};
for &(uid, _) in members {
if uid != winner {
superseded.insert(uid, winner);
}
}
}
superseded
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct IdentityKey {
session: String,
pair_lo: String,
pair_hi: Option<String>,
domain: String,
frame: String,
form: &'static str,
lens: Option<String>,
rater: &'static str,
}
fn identity_key(session_uid: &str, j: &Judgement) -> IdentityKey {
let (pair_lo, pair_hi) = match j.b.as_deref() {
Some(b) if j.a.as_str() <= b => (j.a.clone(), Some(b.to_string())),
Some(b) => (b.to_string(), Some(j.a.clone())),
None => (j.a.clone(), None),
};
IdentityKey {
session: session_uid.to_string(),
pair_lo,
pair_hi,
domain: j.domain.clone(),
frame: j.frame.clone(),
form: form_key(&j.form),
lens: j.lens.clone(),
rater: rater_key(&j.rater),
}
}
fn form_key(form: &RowForm) -> &'static str {
match form {
RowForm::Order => "order",
RowForm::Ratio => "ratio",
RowForm::Anchor => "anchor",
}
}
pub(crate) fn rater_key(rater: &RaterKind) -> &'static str {
match rater {
RaterKind::Human => "human",
RaterKind::Agent => "agent",
RaterKind::Migrated => "migrated",
}
}
fn entity_superseded(j: &Judgement, statuses: &StatusMap) -> bool {
let superseded =
|id: &str| matches!(statuses.get(id), Some(EntityLifecycle::Superseded { .. }));
superseded(&j.a) || j.b.as_deref().is_some_and(superseded)
}
#[cfg(test)]
mod tests {
use super::{
EntityLifecycle, MalformedSupersession, Resolution, ResolutionStatus, StatusMap, resolve,
};
use crate::comparison::{
COMPARISON_SCHEMA, COMPARISON_VERSION, ComparisonSession, DOMAIN_ESTIMATE, DOMAIN_PRIORITY,
DOMAIN_VALUE, FRAME_EQUAL_EFFORT, FRAME_MORE_WORK, FRAME_PREFER_FIRST, FRAME_VALUE_ANCHOR,
Judgement, RaterKind, Response, RowForm, SessionHeader, Tombstone,
};
fn judgement(uid: &str, seq: u32, a: &str, b: &str) -> Judgement {
Judgement {
uid: uid.to_string(),
seq,
a: a.to_string(),
b: Some(b.to_string()),
response: Some(Response::PreferA),
domain: DOMAIN_VALUE.to_string(),
frame: FRAME_EQUAL_EFFORT.to_string(),
form: RowForm::Order,
magnitude: None,
supersedes: None,
lens: None,
rater: RaterKind::Human,
by: None,
note: None,
date: Some("2026-07-10".to_string()),
observed_at: None,
basis: None,
est_lower: None,
est_upper: None,
admission: None,
}
}
fn anchor(uid: &str, seq: u32, a: &str) -> Judgement {
let mut j = judgement(uid, seq, a, "unused");
j.b = None;
j.response = None;
j.frame = FRAME_VALUE_ANCHOR.to_string();
j.form = RowForm::Anchor;
j.magnitude = Some(5.0);
j
}
fn migrated_anchor(uid: &str, seq: u32, a: &str, observed_at: &str) -> Judgement {
let mut j = anchor(uid, seq, a);
j.rater = RaterKind::Migrated;
j.date = None;
j.observed_at = Some(observed_at.to_string());
j
}
fn session(
uid: &str,
judgements: Vec<Judgement>,
tombstones: Vec<Tombstone>,
) -> ComparisonSession {
ComparisonSession {
schema: COMPARISON_SCHEMA.to_string(),
version: COMPARISON_VERSION,
session: SessionHeader {
uid: uid.to_string(),
date: "2026-07-10".to_string(),
audience: None,
},
judgements,
tombstones,
}
}
fn tombstone(uid: &str, target: &str) -> Tombstone {
Tombstone {
uid: uid.to_string(),
seq: 0,
target: target.to_string(),
date: "2026-07-10".to_string(),
note: None,
}
}
fn run(sessions: &[ComparisonSession]) -> Resolution<'_> {
resolve(sessions, &StatusMap::new()).expect("resolve ok")
}
fn status_of<'a>(res: &'a Resolution<'_>, uid: &str) -> &'a ResolutionStatus {
let row = res
.rows
.iter()
.find(|(j, _)| j.uid.as_str() == uid)
.expect("row present");
&row.1
}
fn superseded_by(uid: &str) -> ResolutionStatus {
ResolutionStatus::Superseded {
by: uid.to_string(),
}
}
#[test]
fn r1_tombstone_evicts_its_target() {
let s = session(
"s1",
vec![judgement("j1", 0, "A", "B")],
vec![tombstone("t1", "j1")],
);
let sessions = [s];
let res = run(&sessions);
assert_eq!(status_of(&res, "j1"), &ResolutionStatus::Tombstoned);
}
#[test]
fn r2_chain_leaves_only_the_head_active() {
let mut b = judgement("b", 1, "R", "S");
b.supersedes = Some("a".to_string());
let mut c = judgement("c", 2, "T", "U");
c.supersedes = Some("b".to_string());
let s = session("s1", vec![judgement("a", 0, "P", "Q"), b, c], vec![]);
let sessions = [s];
let res = run(&sessions);
assert_eq!(status_of(&res, "c"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "b"), &superseded_by("c"));
assert_eq!(status_of(&res, "a"), &superseded_by("b"));
}
#[test]
fn r2_tombstoning_chain_head_revives_middle_but_not_root() {
let mut b = judgement("b", 1, "R", "S");
b.supersedes = Some("a".to_string());
let mut c = judgement("c", 2, "T", "U");
c.supersedes = Some("b".to_string());
let s = session(
"s1",
vec![judgement("a", 0, "P", "Q"), b, c],
vec![tombstone("t1", "c")],
);
let sessions = [s];
let res = run(&sessions);
assert_eq!(status_of(&res, "c"), &ResolutionStatus::Tombstoned);
assert_eq!(status_of(&res, "b"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "a"), &superseded_by("b"));
}
#[test]
fn r2_mutual_supersession_cycle_deactivates_all_participants() {
let mut a = judgement("a", 0, "P", "Q");
a.supersedes = Some("b".to_string());
let mut b = judgement("b", 1, "R", "S");
b.supersedes = Some("a".to_string());
let s = session("s1", vec![a, b], vec![]);
let sessions = [s];
let res = run(&sessions);
assert_eq!(status_of(&res, "a"), &ResolutionStatus::Malformed);
assert_eq!(status_of(&res, "b"), &ResolutionStatus::Malformed);
assert_eq!(
res.malformed,
vec![MalformedSupersession {
cycle: vec!["a".to_string(), "b".to_string()],
}]
);
}
#[test]
fn r2_self_supersession_is_a_degenerate_malformed_cycle() {
let mut x = judgement("x", 0, "P", "Q");
x.supersedes = Some("x".to_string());
let sessions = [session("s1", vec![x], vec![])];
let res = run(&sessions);
assert_eq!(status_of(&res, "x"), &ResolutionStatus::Malformed);
assert_eq!(res.malformed[0].cycle, vec!["x".to_string()]);
}
#[test]
fn r2_tombstoned_superseder_revives_its_target() {
let mut x = judgement("x", 1, "R", "S");
x.supersedes = Some("t".to_string());
let s = session(
"s1",
vec![judgement("t", 0, "P", "Q"), x],
vec![tombstone("tomb", "x")],
);
let sessions = [s];
let res = run(&sessions);
assert_eq!(status_of(&res, "x"), &ResolutionStatus::Tombstoned);
assert_eq!(status_of(&res, "t"), &ResolutionStatus::Active);
}
#[test]
fn r2_superseder_of_a_tombstoned_target_stays_active() {
let mut x = judgement("x", 1, "R", "S");
x.supersedes = Some("t".to_string());
let s = session(
"s1",
vec![judgement("t", 0, "P", "Q"), x],
vec![tombstone("tomb", "t")],
);
let sessions = [s];
let res = run(&sessions);
assert_eq!(status_of(&res, "t"), &ResolutionStatus::Tombstoned);
assert_eq!(status_of(&res, "x"), &ResolutionStatus::Active);
}
#[test]
fn r2_unknown_supersedes_target_warns_and_holder_stays_active() {
let mut x = judgement("x", 0, "P", "Q");
x.supersedes = Some("ghost".to_string());
let sessions = [session("s1", vec![x], vec![])];
let res = run(&sessions);
assert_eq!(status_of(&res, "x"), &ResolutionStatus::Active);
assert_eq!(res.unknown_supersedes.len(), 1);
assert_eq!(res.unknown_supersedes[0].row, "x");
assert_eq!(res.unknown_supersedes[0].target, "ghost");
}
#[test]
fn r3_within_file_identity_higher_seq_wins_unordered_pair() {
let sessions = [session(
"s1",
vec![judgement("p", 0, "X", "Y"), judgement("q", 1, "Y", "X")],
vec![],
)];
let res = run(&sessions);
assert_eq!(status_of(&res, "q"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "p"), &superseded_by("q"));
}
#[test]
fn r3_cross_session_same_key_is_concurrent_both_active() {
let s1 = session("s1", vec![judgement("p", 0, "X", "Y")], vec![]);
let s2 = session("s2", vec![judgement("q", 0, "X", "Y")], vec![]);
let sessions = [s1, s2];
let res = run(&sessions);
assert_eq!(status_of(&res, "p"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "q"), &ResolutionStatus::Active);
}
#[test]
fn r4_priority_domain_row_is_inert() {
let mut j = judgement("j1", 0, "A", "B");
j.domain = DOMAIN_PRIORITY.to_string();
j.frame = FRAME_PREFER_FIRST.to_string();
let sessions = [session("s1", vec![j], vec![])];
let res = run(&sessions);
assert_eq!(status_of(&res, "j1"), &ResolutionStatus::InertDomain);
}
#[test]
fn r5_lens_tagged_row_is_inert() {
let mut j = judgement("j1", 0, "A", "B");
j.lens = Some("user-value".to_string());
let sessions = [session("s1", vec![j], vec![])];
let res = run(&sessions);
assert_eq!(status_of(&res, "j1"), &ResolutionStatus::InertLens);
}
#[test]
fn r6_superseded_entity_makes_its_rows_inert() {
let life = EntityLifecycle::Superseded {
by: "SL-999".to_string(),
};
if let EntityLifecycle::Superseded { by } = &life {
assert_eq!(by, "SL-999");
}
let mut statuses = StatusMap::new();
statuses.insert("SL-100".to_string(), life);
let s = session("s1", vec![judgement("j1", 0, "SL-100", "SL-200")], vec![]);
let sessions = [s];
let res = resolve(&sessions, &statuses).expect("resolve ok");
assert_eq!(status_of(&res, "j1"), &ResolutionStatus::InertLifecycle);
}
#[test]
fn r6_terminal_entity_rows_stay_active() {
let mut statuses = StatusMap::new();
statuses.insert("SL-100".to_string(), EntityLifecycle::Terminal);
let s = session("s1", vec![judgement("j1", 0, "SL-100", "SL-200")], vec![]);
let sessions = [s];
let res = resolve(&sessions, &statuses).expect("resolve ok");
assert_eq!(status_of(&res, "j1"), &ResolutionStatus::Active);
}
#[test]
fn r6_empty_status_map_is_a_no_op() {
let sessions = [session("s1", vec![judgement("j1", 0, "A", "B")], vec![])];
let res = run(&sessions);
assert_eq!(status_of(&res, "j1"), &ResolutionStatus::Active);
}
fn estimate_judgement(uid: &str, seq: u32, a: &str, b: &str) -> Judgement {
let mut j = judgement(uid, seq, a, b);
j.domain = DOMAIN_ESTIMATE.to_string();
j.frame = FRAME_MORE_WORK.to_string();
j
}
#[test]
fn estimate_domain_row_resolves_active() {
let sessions = [session(
"s1",
vec![estimate_judgement("j1", 0, "A", "B")],
vec![],
)];
let res = run(&sessions);
assert_eq!(status_of(&res, "j1"), &ResolutionStatus::Active);
}
#[test]
fn same_pair_value_and_estimate_rows_stay_concurrent() {
let sessions = [session(
"s1",
vec![
judgement("v", 0, "X", "Y"),
estimate_judgement("e", 1, "Y", "X"),
],
vec![],
)];
let res = run(&sessions);
assert_eq!(status_of(&res, "v"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "e"), &ResolutionStatus::Active);
}
#[test]
fn r3_revision_scopes_within_the_estimate_domain() {
let sessions = [session(
"s1",
vec![
judgement("v", 0, "X", "Y"),
estimate_judgement("e1", 1, "X", "Y"),
estimate_judgement("e2", 2, "Y", "X"),
],
vec![],
)];
let res = run(&sessions);
assert_eq!(status_of(&res, "v"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "e1"), &superseded_by("e2"));
assert_eq!(status_of(&res, "e2"), &ResolutionStatus::Active);
}
#[test]
fn duplicate_uid_identical_content_collapses_to_one_row() {
let s1 = session("s1", vec![judgement("dup", 0, "A", "B")], vec![]);
let s2 = session("s2", vec![judgement("dup", 0, "A", "B")], vec![]);
let sessions = [s1, s2];
let res = run(&sessions);
let count = res
.rows
.iter()
.filter(|(j, _)| j.uid.as_str() == "dup")
.count();
assert_eq!(count, 1);
}
#[test]
fn same_uid_differing_content_is_a_load_error() {
let s1 = session("s1", vec![judgement("x", 0, "A", "B")], vec![]);
let mut conflicting = judgement("x", 0, "A", "B");
conflicting.response = Some(Response::PreferB);
let s2 = session("s2", vec![conflicting], vec![]);
let err = resolve(&[s1, s2], &StatusMap::new()).unwrap_err();
assert!(err.to_string().contains("differing content"));
assert!(err.to_string().contains("`x`"));
}
fn det_fixture(name: &str) -> ComparisonSession {
match name {
"s1" => {
let mut b = judgement("b", 0, "R", "S");
b.supersedes = Some("a".to_string());
session("s1", vec![b], vec![])
}
"s2" => session("s2", vec![judgement("a", 0, "P", "Q")], vec![]),
_ => {
let mut c = judgement("c", 0, "T", "U");
c.supersedes = Some("d".to_string());
let mut d = judgement("d", 1, "V", "W");
d.supersedes = Some("c".to_string());
session("s3", vec![c, d], vec![])
}
}
}
#[test]
fn resolution_is_deterministic_across_session_merge_order() {
let build = |order: [&str; 3]| -> Vec<ComparisonSession> {
order.iter().map(|&n| det_fixture(n)).collect()
};
let p1 = build(["s1", "s2", "s3"]);
let p2 = build(["s3", "s2", "s1"]);
let p3 = build(["s2", "s3", "s1"]);
let r1 = resolve(&p1, &StatusMap::new()).expect("resolve ok");
let r2 = resolve(&p2, &StatusMap::new()).expect("resolve ok");
let r3 = resolve(&p3, &StatusMap::new()).expect("resolve ok");
assert_eq!(r1, r2);
assert_eq!(r1, r3);
assert_eq!(status_of(&r1, "a"), &superseded_by("b"));
assert_eq!(status_of(&r1, "c"), &ResolutionStatus::Malformed);
}
#[test]
fn ordering_date_orders_mixed_migrated_and_live_rows() {
let s1 = session(
"s1",
vec![judgement("live-10", 0, "A", "B")], vec![],
);
let s2 = session(
"s2",
vec![
migrated_anchor("mig-09", 0, "C", "2026-07-09"),
migrated_anchor("mig-10", 1, "D", "2026-07-10"),
],
vec![],
);
let order_of = |sessions: &[ComparisonSession]| -> Vec<String> {
resolve(sessions, &StatusMap::new())
.expect("resolve ok")
.rows
.iter()
.map(|(j, _)| j.uid.clone())
.collect()
};
let forward = order_of(&[s1, s2]);
assert_eq!(forward, ["mig-09", "live-10", "mig-10"]);
let s1 = session("s1", vec![judgement("live-10", 0, "A", "B")], vec![]);
let s2 = session(
"s2",
vec![
migrated_anchor("mig-09", 0, "C", "2026-07-09"),
migrated_anchor("mig-10", 1, "D", "2026-07-10"),
],
vec![],
);
assert_eq!(order_of(&[s2, s1]), forward, "merge-order independent");
}
#[test]
fn r3_same_subject_anchor_rows_implicitly_revise() {
let sessions = [session(
"s1",
vec![anchor("p", 0, "SL-100"), anchor("q", 1, "SL-100")],
vec![],
)];
let res = run(&sessions);
assert_eq!(status_of(&res, "q"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "p"), &superseded_by("q"));
}
#[test]
fn anchor_and_order_rows_on_the_same_subject_stay_concurrent() {
let sessions = [session(
"s1",
vec![
anchor("anch", 0, "SL-100"),
judgement("ord", 1, "SL-100", "SL-200"),
],
vec![],
)];
let res = run(&sessions);
assert_eq!(status_of(&res, "anch"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "ord"), &ResolutionStatus::Active);
}
#[test]
fn human_and_migrated_anchors_on_one_subject_stay_concurrent() {
let sessions = [session(
"s1",
vec![
anchor("hum", 0, "SL-100"),
migrated_anchor("mig", 1, "SL-100", "2026-07-16"),
],
vec![],
)];
let res = run(&sessions);
assert_eq!(status_of(&res, "hum"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "mig"), &ResolutionStatus::Active);
}
#[test]
fn cross_session_same_subject_anchors_are_concurrent() {
let s1 = session("s1", vec![anchor("p", 0, "SL-100")], vec![]);
let s2 = session("s2", vec![anchor("q", 0, "SL-100")], vec![]);
let sessions = [s1, s2];
let res = run(&sessions);
assert_eq!(status_of(&res, "p"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "q"), &ResolutionStatus::Active);
}
#[test]
fn r6_anchor_row_lifecycle_follows_its_single_subject() {
let mut statuses = StatusMap::new();
statuses.insert(
"SL-100".to_string(),
EntityLifecycle::Superseded {
by: "SL-999".to_string(),
},
);
statuses.insert("SL-200".to_string(), EntityLifecycle::Terminal);
let s = session(
"s1",
vec![
anchor("gone", 0, "SL-100"),
anchor("done", 1, "SL-200"),
anchor("live", 2, "SL-300"),
],
vec![],
);
let sessions = [s];
let res = resolve(&sessions, &statuses).expect("resolve ok");
assert_eq!(status_of(&res, "gone"), &ResolutionStatus::InertLifecycle);
assert_eq!(status_of(&res, "done"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "live"), &ResolutionStatus::Active);
}
#[test]
fn anchor_rows_ride_tombstones_and_supersession_chains() {
let a = anchor("a", 0, "SL-100");
let mut b = anchor("b", 1, "SL-100");
b.rater = RaterKind::Agent; b.supersedes = Some("a".to_string());
let mut c = migrated_anchor("c", 2, "SL-100", "2026-07-16");
c.supersedes = Some("b".to_string());
let d = anchor("d", 3, "SL-200");
let s = session("s1", vec![a, b, c, d], vec![tombstone("t1", "d")]);
let sessions = [s];
let res = run(&sessions);
assert_eq!(status_of(&res, "c"), &ResolutionStatus::Active);
assert_eq!(status_of(&res, "b"), &superseded_by("c"));
assert_eq!(status_of(&res, "a"), &superseded_by("b"));
assert_eq!(status_of(&res, "d"), &ResolutionStatus::Tombstoned);
}
#[test]
fn anchor_supersession_cycle_is_malformed() {
let mut p = anchor("p", 0, "SL-100");
p.supersedes = Some("q".to_string());
let mut q = anchor("q", 0, "SL-200");
q.supersedes = Some("p".to_string());
let sessions = [session("s1", vec![p, q], vec![])];
let res = run(&sessions);
assert_eq!(status_of(&res, "p"), &ResolutionStatus::Malformed);
assert_eq!(status_of(&res, "q"), &ResolutionStatus::Malformed);
assert_eq!(
res.malformed,
vec![MalformedSupersession {
cycle: vec!["p".to_string(), "q".to_string()],
}]
);
}
#[test]
fn lensed_anchor_row_is_inert() {
let mut j = anchor("j1", 0, "SL-100");
j.lens = Some("user-value".to_string());
let sessions = [session("s1", vec![j], vec![])];
let res = run(&sessions);
assert_eq!(status_of(&res, "j1"), &ResolutionStatus::InertLens);
}
}