Skip to main content

corpora_core/
graph.rs

1//! The immutable, queryable graph rules and adapters consume. `build` is pure:
2//! `records -> (Graph, Vec<Diagnostic>)`, resolving supersession into three views —
3//! **raw** (declared edges), **effective** (adopted successor, chased to the terminal),
4//! and **pending** (a proposed/open replacement not yet adopted) — and flagging
5//! duplicate ids, kind mismatches, multiple adopted successors, and cycles.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Arc;
9
10use crate::diagnostic::Diagnostic;
11use crate::model::{DocPath, Facet, Id, Lifecycle, Record, Status};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Relation {
15    DependsOn,
16    Supports,
17    Related,
18    Link,
19}
20
21/// A typed (structured) citation from one record to another.
22#[derive(Clone, Debug)]
23pub struct Cite {
24    pub target: Id,
25    pub relation: Relation,
26}
27
28pub struct Graph {
29    records: Vec<Arc<Record>>,
30    by_id: BTreeMap<Id, usize>,
31    /// `X -> Y`: Y is the (single) **adopted** successor that supersedes X.
32    adopted_successor: BTreeMap<Id, Id>,
33    /// `X -> [Y…]`: proposed/open records that supersede X but aren't adopted yet.
34    pending: BTreeMap<Id, Vec<Id>>,
35    /// Targets with more than one adopted successor: superseded, but with no resolvable
36    /// terminal. Tracked explicitly because map-absence can't tell "genuinely terminal"
37    /// from "removed because ambiguous".
38    ambiguous: BTreeSet<Id>,
39}
40
41/// Has this record reached an adopted state? Decisions go by their adoption `status`;
42/// other kinds are adopted once they leave `Draft`.
43fn record_is_adopted(r: &Record) -> bool {
44    match &r.facet {
45        Facet::Decision(d) => matches!(
46            d.status,
47            Status::Accepted | Status::Superseded | Status::Deprecated
48        ),
49        _ => r.lifecycle != Lifecycle::Draft,
50    }
51}
52
53impl Graph {
54    pub fn build(records: Vec<Arc<Record>>) -> (Graph, Vec<Diagnostic>) {
55        let mut by_id = BTreeMap::new();
56        let mut adopted_successor: BTreeMap<Id, Id> = BTreeMap::new();
57        let mut pending: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
58        let mut ambiguous: BTreeSet<Id> = BTreeSet::new();
59        let mut diags = Vec::new();
60
61        // Pass 1: index ids, flag duplicates.
62        for (i, r) in records.iter().enumerate() {
63            if let Some(id) = &r.id {
64                if by_id.insert(id.clone(), i).is_some() {
65                    diags.push(Diagnostic::error(
66                        "DUP",
67                        &r.path,
68                        format!("duplicate id {}", id.0),
69                    ));
70                }
71            }
72        }
73
74        // Pass 2: classify supersession edges. An edge with a dangling target or a kind
75        // mismatch is diagnosed and *dropped* — it must not reach the resolved maps, or an
76        // unknown id would look superseded and a cross-kind atom could become a migration
77        // target. Adopted successors are collected per target so arity is known before any
78        // edge becomes "effective".
79        let mut adopted_cands: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
80        for r in records.iter() {
81            let Some(src) = &r.id else { continue };
82            let adopted = record_is_adopted(r);
83            for tgt in &r.edges.supersedes {
84                let valid = match by_id.get(tgt) {
85                    None => {
86                        diags.push(Diagnostic::warn(
87                            "SUPERSEDE",
88                            &r.path,
89                            format!("{} supersedes unknown id {}", src.0, tgt.0),
90                        ));
91                        false
92                    }
93                    Some(&ti) => {
94                        let tkind = records[ti].kind;
95                        if tkind != r.kind {
96                            diags.push(Diagnostic::error(
97                                "KIND",
98                                &r.path,
99                                format!(
100                                    "{} ({:?}) supersedes {} of different kind ({:?})",
101                                    src.0, r.kind, tgt.0, tkind
102                                ),
103                            ));
104                            false
105                        } else {
106                            true
107                        }
108                    }
109                };
110                if !valid {
111                    continue;
112                }
113
114                if adopted {
115                    let v = adopted_cands.entry(tgt.clone()).or_default();
116                    if !v.contains(src) {
117                        v.push(src.clone());
118                    }
119                } else {
120                    let v = pending.entry(tgt.clone()).or_default();
121                    if !v.contains(src) {
122                        v.push(src.clone());
123                    }
124                }
125            }
126        }
127
128        // Resolve adopted candidates: exactly one => the effective successor; more than one
129        // => ambiguous, so the target gets NO effective successor (the result is then
130        // independent of input ordering). The error is anchored on the contested target.
131        for (tgt, srcs) in &adopted_cands {
132            if srcs.len() == 1 {
133                adopted_successor.insert(tgt.clone(), srcs[0].clone());
134            } else {
135                ambiguous.insert(tgt.clone());
136                let mut names: Vec<&str> = srcs.iter().map(|i| i.0.as_str()).collect();
137                names.sort_unstable();
138                let path = by_id
139                    .get(tgt)
140                    .map(|&i| records[i].path.clone())
141                    .unwrap_or_else(|| DocPath(tgt.0.clone()));
142                diags.push(Diagnostic::error(
143                    "SUPERSEDE",
144                    &path,
145                    format!("{} has multiple adopted successors: {}", tgt.0, names.join(", ")),
146                ));
147            }
148        }
149
150        // Pass 3: cycle detection over the adopted-successor chain (report each cycle once).
151        let mut cyclic: BTreeSet<Id> = BTreeSet::new();
152        for start in adopted_successor.keys() {
153            if cyclic.contains(start) {
154                continue;
155            }
156            let mut seen: Vec<Id> = Vec::new();
157            let mut cur = start.clone();
158            loop {
159                if cyclic.contains(&cur) {
160                    break; // this chain feeds a cycle that was already reported
161                }
162                if let Some(pos) = seen.iter().position(|x| *x == cur) {
163                    for id in &seen[pos..] {
164                        cyclic.insert(id.clone());
165                    }
166                    let mut chain: Vec<String> =
167                        seen[pos..].iter().map(|i| i.0.clone()).collect();
168                    chain.push(cur.0.clone());
169                    let path = by_id
170                        .get(&cur)
171                        .map(|&i| records[i].path.clone())
172                        .unwrap_or_else(|| DocPath(cur.0.clone()));
173                    diags.push(Diagnostic::error(
174                        "CYCLE",
175                        &path,
176                        format!("supersession cycle: {}", chain.join(" → ")),
177                    ));
178                    break;
179                }
180                seen.push(cur.clone());
181                match adopted_successor.get(&cur) {
182                    Some(next) => cur = next.clone(),
183                    None => break,
184                }
185            }
186        }
187
188        (
189            Graph {
190                records,
191                by_id,
192                adopted_successor,
193                pending,
194                ambiguous,
195            },
196            diags,
197        )
198    }
199
200    pub fn records(&self) -> impl Iterator<Item = &Record> {
201        self.records.iter().map(|a| a.as_ref())
202    }
203
204    pub fn get(&self, id: &Id) -> Option<&Record> {
205        self.by_id.get(id).map(|&i| self.records[i].as_ref())
206    }
207
208    /// True iff `id` has been superseded — it carries at least one adopted successor edge,
209    /// whether or not that resolves to a single live atom. Direct-edge semantics only;
210    /// whether a *safe* terminal migration exists is [`effective_successor`]'s question.
211    ///
212    /// [`effective_successor`]: Graph::effective_successor
213    pub fn is_superseded(&self, id: &Id) -> bool {
214        self.adopted_successor.contains_key(id) || self.ambiguous.contains(id)
215    }
216
217    /// The terminal adopted successor of `id` — the live atom a stale citation should
218    /// migrate to — chasing multi-hop chains. Returns `None` when no *safe* terminal
219    /// exists: the chain enters a cycle, or it runs through an ambiguous node (one with
220    /// multiple adopted successors), which is itself unresolved. Downstream rules must not
221    /// recommend a migration target in those cases.
222    pub fn effective_successor(&self, id: &Id) -> Option<&Id> {
223        if self.ambiguous.contains(id) {
224            return None;
225        }
226        let mut seen: BTreeSet<Id> = BTreeSet::new();
227        seen.insert(id.clone());
228        let mut cur = self.adopted_successor.get(id)?;
229        loop {
230            if !seen.insert(cur.clone()) {
231                return None; // cycle: no well-defined terminal successor
232            }
233            if self.ambiguous.contains(cur) {
234                return None; // path runs through an unresolved (multiply-superseded) node
235            }
236            match self.adopted_successor.get(cur) {
237                Some(next) => cur = next,
238                None => return Some(cur),
239            }
240        }
241    }
242
243    /// Proposed/open records superseding `id` that haven't been adopted — the
244    /// pending-supersession view (e.g. open forks).
245    pub fn pending_successors(&self, id: &Id) -> &[Id] {
246        self.pending.get(id).map(|v| v.as_slice()).unwrap_or(&[])
247    }
248
249    /// Typed edges out of `from` (depends_on / supports / related) — the E3 *error* path.
250    pub fn structured_citations(&self, from: &Id) -> Vec<Cite> {
251        let Some(r) = self.get(from) else {
252            return Vec::new();
253        };
254        let mut v = Vec::new();
255        for t in &r.edges.depends_on {
256            v.push(Cite {
257                target: t.clone(),
258                relation: Relation::DependsOn,
259            });
260        }
261        for t in &r.edges.supports {
262            v.push(Cite {
263                target: t.clone(),
264                relation: Relation::Supports,
265            });
266        }
267        for t in &r.edges.related {
268            v.push(Cite {
269                target: t.clone(),
270                relation: Relation::Related,
271            });
272        }
273        for t in &r.body.link_refs {
274            v.push(Cite {
275                target: t.clone(),
276                relation: Relation::Link,
277            });
278        }
279        v
280    }
281
282    /// Bare prose mentions out of `from` — the E3 *warning* path.
283    pub fn bare_mentions(&self, from: &Id) -> Vec<Id> {
284        self.get(from)
285            .map(|r| r.body.bare_mentions.clone())
286            .unwrap_or_default()
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::diagnostic::Severity;
294    use crate::model::*;
295
296    fn id(s: &str) -> Id {
297        Id(s.into())
298    }
299
300    /// A decision record with the given adoption status and `supersedes` targets.
301    fn decision(name: &str, status: Status, supersedes: &[&str]) -> Record {
302        let mut r = Record::minimal(
303            Some(id(name)),
304            DocPath(format!("{name}.md")),
305            Kind::Decision,
306            Lifecycle::Current,
307            Authority::Normative,
308            Facet::Decision(DecisionFacet {
309                status,
310                date: Date("2026-06-21".into()),
311                implementation: None,
312                fork: None,
313                realized_by: vec![],
314            }),
315        );
316        r.edges.supersedes = supersedes.iter().map(|s| id(s)).collect();
317        r
318    }
319
320    fn build(records: Vec<Record>) -> (Graph, Vec<Diagnostic>) {
321        Graph::build(records.into_iter().map(Arc::new).collect())
322    }
323
324    fn codes(diags: &[Diagnostic]) -> Vec<&str> {
325        diags.iter().map(|d| d.code.as_str()).collect()
326    }
327
328    #[test]
329    fn supersession_resolves() {
330        let d1 = decision("D1", Status::Superseded, &[]);
331        let d2 = decision("D2", Status::Accepted, &["D1"]);
332        let (g, diags) = build(vec![d1, d2]);
333        assert!(diags.is_empty(), "{diags:?}");
334        assert!(g.is_superseded(&id("D1")));
335        assert_eq!(g.effective_successor(&id("D1")), Some(&id("D2")));
336        assert!(!g.is_superseded(&id("D2")));
337    }
338
339    #[test]
340    fn effective_successor_chases_to_terminal() {
341        let d1 = decision("D1", Status::Superseded, &[]);
342        let d2 = decision("D2", Status::Superseded, &["D1"]);
343        let d3 = decision("D3", Status::Accepted, &["D2"]);
344        let (g, diags) = build(vec![d1, d2, d3]);
345        assert!(diags.is_empty(), "{diags:?}");
346        assert_eq!(g.effective_successor(&id("D1")), Some(&id("D3")));
347        assert_eq!(g.effective_successor(&id("D2")), Some(&id("D3")));
348    }
349
350    #[test]
351    fn proposed_successor_is_pending_not_effective() {
352        let d1 = decision("D1", Status::Accepted, &[]);
353        let d2 = decision("D2", Status::Proposed, &["D1"]); // not yet adopted
354        let (g, diags) = build(vec![d1, d2]);
355        assert!(diags.is_empty(), "{diags:?}");
356        assert!(!g.is_superseded(&id("D1")));
357        assert_eq!(g.effective_successor(&id("D1")), None);
358        assert_eq!(g.pending_successors(&id("D1")), &[id("D2")]);
359    }
360
361    #[test]
362    fn two_adopted_successors_conflict() {
363        let d1 = decision("D1", Status::Superseded, &[]);
364        let d2 = decision("D2", Status::Accepted, &["D1"]);
365        let d3 = decision("D3", Status::Accepted, &["D1"]);
366        let (g, diags) = build(vec![d1, d2, d3]);
367        assert!(codes(&diags).contains(&"SUPERSEDE"), "{diags:?}");
368        // Ambiguous target => superseded (direct edges exist) but NO safe terminal.
369        assert!(g.is_superseded(&id("D1")));
370        assert_eq!(g.effective_successor(&id("D1")), None);
371    }
372
373    #[test]
374    fn ambiguity_is_order_independent() {
375        // Same three records, reversed input order: the verdict must not change.
376        let d1 = decision("D1", Status::Superseded, &[]);
377        let d2 = decision("D2", Status::Accepted, &["D1"]);
378        let d3 = decision("D3", Status::Accepted, &["D1"]);
379        let (g, _) = build(vec![d3, d2, d1]);
380        assert_eq!(g.effective_successor(&id("D1")), None);
381    }
382
383    #[test]
384    fn ambiguity_is_not_transitively_resolvable() {
385        // B supersedes A; C and D both supersede B. A has a single successor (B), but B is
386        // ambiguous, so A has no *safe* terminal even though it is superseded.
387        let a = decision("A", Status::Superseded, &[]);
388        let b = decision("B", Status::Superseded, &["A"]);
389        let c = decision("C", Status::Accepted, &["B"]);
390        let d = decision("D", Status::Accepted, &["B"]);
391        let (g, diags) = build(vec![a, b, c, d]);
392
393        assert!(g.is_superseded(&id("A")), "A is superseded by B");
394        assert_eq!(
395            g.effective_successor(&id("A")),
396            None,
397            "A's chain runs through the ambiguous B, so no terminal is safe"
398        );
399        // The ambiguity error is anchored on the contested target, B.
400        let sup = diags
401            .iter()
402            .find(|x| x.code == "SUPERSEDE")
403            .expect("ambiguity should be reported");
404        assert_eq!(sup.path, DocPath("B.md".into()));
405    }
406
407    #[test]
408    fn kind_must_be_preserved() {
409        let d1 = decision("D1", Status::Superseded, &[]);
410        let mut a1 = Record::minimal(
411            Some(id("A1")),
412            DocPath("a1.md".into()),
413            Kind::Axiom,
414            Lifecycle::Current,
415            Authority::Axiomatic,
416            Facet::Axiom,
417        );
418        a1.edges.supersedes = vec![id("D1")];
419        let (g, diags) = build(vec![d1, a1]);
420        assert!(codes(&diags).contains(&"KIND"), "{diags:?}");
421        // The kind-mismatched edge is dropped: the target is not superseded.
422        assert!(!g.is_superseded(&id("D1")));
423        assert_eq!(g.effective_successor(&id("D1")), None);
424    }
425
426    #[test]
427    fn supersession_cycle_detected() {
428        let d1 = decision("D1", Status::Accepted, &["D2"]);
429        let d2 = decision("D2", Status::Accepted, &["D1"]);
430        let (g, diags) = build(vec![d1, d2]);
431        let cycle: Vec<_> = diags.iter().filter(|d| d.code == "CYCLE").collect();
432        assert_eq!(cycle.len(), 1, "exactly one cycle report: {diags:?}");
433        // A cycle has no well-defined terminal, so no migration target is offered.
434        assert_eq!(g.effective_successor(&id("D1")), None);
435        assert_eq!(g.effective_successor(&id("D2")), None);
436    }
437
438    #[test]
439    fn cycle_reported_once_through_tails() {
440        // Cycle B<->C, with tails A→B and E→C feeding into it. Report it exactly once,
441        // even though a tail that starts after detection still walks into the cycle.
442        let a = decision("A", Status::Accepted, &[]);
443        let b = decision("B", Status::Accepted, &["A", "C"]);
444        let c = decision("C", Status::Accepted, &["B", "E"]);
445        let e = decision("E", Status::Accepted, &[]);
446        let (_g, diags) = build(vec![a, b, c, e]);
447        let cycle: Vec<_> = diags.iter().filter(|d| d.code == "CYCLE").collect();
448        assert_eq!(cycle.len(), 1, "one report despite multiple tails: {diags:?}");
449    }
450
451    #[test]
452    fn dangling_supersedes_warns_and_drops_edge() {
453        let d2 = decision("D2", Status::Accepted, &["D1"]); // D1 absent
454        let (g, diags) = build(vec![d2]);
455        assert!(codes(&diags).contains(&"SUPERSEDE"), "{diags:?}");
456        assert_eq!(diags[0].severity, Severity::Warning);
457        // The unknown id must not appear superseded.
458        assert!(!g.is_superseded(&id("D1")));
459    }
460
461    #[test]
462    fn duplicate_id_flagged() {
463        let a = decision("D1", Status::Accepted, &[]);
464        let mut b = decision("D1", Status::Accepted, &[]);
465        b.path = DocPath("b.md".into());
466        let (_g, diags) = build(vec![a, b]);
467        assert_eq!(codes(&diags), vec!["DUP"]);
468    }
469}