Skip to main content

mimir_core/
dag.rs

1//! Supersession DAG — in-memory representation of the four edge kinds
2//! defined by `docs/concepts/temporal-model.md` § 6.
3//!
4//! The DAG is a workspace-scoped graph of memory IDs connected by
5//! typed edges:
6//!
7//! - `Supersedes` — closes the target's validity (§ 5.1, § 5.2).
8//! - `Corrects` — Episodic-only correction link; target's validity is
9//!   preserved (§ 5.3).
10//! - `StaleParent` — Inferential-only stale-flag link when a parent
11//!   was superseded (§ 5.4).
12//! - `Reconfirms` — Inferential re-derivation that confirms the
13//!   original conclusion (§ 5.4).
14//!
15//! Invariants enforced here:
16//!
17//! 1. **Acyclicity.** The union of all edges forms a DAG. A write
18//!    whose edge would close a cycle is rejected with
19//!    [`DagError::SupersessionCycle`].
20//! 2. **No self-edges.** An edge `from == to` is rejected with
21//!    [`DagError::SelfEdge`]. Supersession of `M` by `M` has no
22//!    meaningful semantics.
23//!
24//! Invariants deferred to the bind layer (milestone 6.3):
25//!
26//! - **Source precedes target in transaction time** (§ 6.2 invariant
27//!   #2). The DAG does not store memory metadata; the caller
28//!   (auto-supersession logic in `bind`) already has `committed_at`
29//!   for both endpoints and checks this before calling `add_edge`.
30//! - **Supersedes closes validity** (§ 6.2 invariant #3). Setting the
31//!   target's `invalid_at` is a memory-record mutation, not a DAG
32//!   concern.
33
34use std::collections::{BTreeMap, BTreeSet};
35
36use thiserror::Error;
37
38use crate::canonical::CanonicalRecord;
39use crate::clock::ClockTime;
40use crate::symbol::SymbolId;
41
42/// Kind of supersession edge.
43#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
44pub enum EdgeKind {
45    /// `from supersedes to` — target's validity closes at this edge.
46    Supersedes,
47    /// `from corrects to` — Episodic-only; target stays valid.
48    Corrects,
49    /// `from has-stale-parent to` — Inferential-only; flags `from`
50    /// stale when `to` is superseded.
51    StaleParent,
52    /// `from reconfirms to` — Inferential re-derivation confirming
53    /// the original conclusion.
54    Reconfirms,
55}
56
57/// One edge in the supersession DAG.
58#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
59pub struct Edge {
60    /// Edge kind.
61    pub kind: EdgeKind,
62    /// Source memory.
63    pub from: SymbolId,
64    /// Target memory.
65    pub to: SymbolId,
66    /// Timestamp the edge was applied.
67    pub at: ClockTime,
68}
69
70impl Edge {
71    /// Build an [`Edge`] from a canonical edge-family record.
72    /// Returns `None` for non-edge variants.
73    #[must_use]
74    pub fn try_from_record(record: &CanonicalRecord) -> Option<Self> {
75        let (kind, r) = match record {
76            CanonicalRecord::Supersedes(r) => (EdgeKind::Supersedes, r),
77            CanonicalRecord::Corrects(r) => (EdgeKind::Corrects, r),
78            CanonicalRecord::StaleParent(r) => (EdgeKind::StaleParent, r),
79            CanonicalRecord::Reconfirms(r) => (EdgeKind::Reconfirms, r),
80            _ => return None,
81        };
82        Some(Self {
83            kind,
84            from: r.from,
85            to: r.to,
86            at: r.at,
87        })
88    }
89}
90
91/// Errors produced by [`SupersessionDag`] mutations.
92#[derive(Clone, Debug, Error, PartialEq, Eq)]
93pub enum DagError {
94    /// The edge would close a cycle in the DAG. Per
95    /// `temporal-model.md` § 6.2 invariant #1, cycles are forbidden.
96    #[error("supersession cycle: {kind:?} edge {from:?} -> {to:?} would close a cycle")]
97    SupersessionCycle {
98        /// Source of the rejected edge.
99        from: SymbolId,
100        /// Target of the rejected edge.
101        to: SymbolId,
102        /// Kind of the rejected edge.
103        kind: EdgeKind,
104    },
105
106    /// The edge's endpoints are identical (`from == to`). Supersession
107    /// of a memory by itself has no meaningful semantics.
108    #[error("self-edge is forbidden: {kind:?} edge on {memory:?}")]
109    SelfEdge {
110        /// The memory that appeared on both sides.
111        memory: SymbolId,
112        /// Edge kind.
113        kind: EdgeKind,
114    },
115}
116
117/// Workspace-scoped supersession graph.
118///
119/// Holds the four edge kinds in a single indexed structure: a flat
120/// list of edges plus outgoing / incoming adjacency indices keyed by
121/// memory ID. Acyclicity is enforced at every `add_edge` call via a
122/// forward-reachability DFS from the proposed target.
123#[derive(Clone, Debug, Default, PartialEq, Eq)]
124pub struct SupersessionDag {
125    edges: Vec<Edge>,
126    outgoing: BTreeMap<SymbolId, Vec<usize>>,
127    incoming: BTreeMap<SymbolId, Vec<usize>>,
128}
129
130impl SupersessionDag {
131    /// Construct an empty DAG.
132    #[must_use]
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// Add an edge to the DAG.
138    ///
139    /// # Errors
140    ///
141    /// - [`DagError::SelfEdge`] if `edge.from == edge.to`.
142    /// - [`DagError::SupersessionCycle`] if the edge would close a
143    ///   cycle (i.e. `edge.to` already reaches `edge.from` via the
144    ///   existing edge set of any kind).
145    pub fn add_edge(&mut self, edge: Edge) -> Result<(), DagError> {
146        if edge.from == edge.to {
147            return Err(DagError::SelfEdge {
148                memory: edge.from,
149                kind: edge.kind,
150            });
151        }
152        if self.reaches(edge.to, edge.from) {
153            tracing::warn!(
154                target: "mimir.dag.cycle_rejected",
155                from = %edge.from,
156                to = %edge.to,
157                edge_kind = ?edge.kind,
158                "supersession cycle rejected",
159            );
160            return Err(DagError::SupersessionCycle {
161                from: edge.from,
162                to: edge.to,
163                kind: edge.kind,
164            });
165        }
166        let idx = self.edges.len();
167        self.outgoing.entry(edge.from).or_default().push(idx);
168        self.incoming.entry(edge.to).or_default().push(idx);
169        self.edges.push(edge);
170        Ok(())
171    }
172
173    /// Forward reachability: does any path from `start` reach `target`
174    /// by following `from -> to` edges? Linear in DAG size.
175    fn reaches(&self, start: SymbolId, target: SymbolId) -> bool {
176        if start == target {
177            return true;
178        }
179        let mut visited = BTreeSet::new();
180        let mut stack = vec![start];
181        while let Some(node) = stack.pop() {
182            if !visited.insert(node) {
183                continue;
184            }
185            if let Some(indices) = self.outgoing.get(&node) {
186                for &i in indices {
187                    let next = self.edges[i].to;
188                    if next == target {
189                        return true;
190                    }
191                    stack.push(next);
192                }
193            }
194        }
195        false
196    }
197
198    /// All edges in insertion order.
199    #[must_use]
200    pub fn edges(&self) -> &[Edge] {
201        &self.edges
202    }
203
204    /// Edge count.
205    #[must_use]
206    pub fn len(&self) -> usize {
207        self.edges.len()
208    }
209
210    /// `true` if the DAG has no edges.
211    #[must_use]
212    pub fn is_empty(&self) -> bool {
213        self.edges.is_empty()
214    }
215
216    /// Iterator over edges leaving `memory` (i.e. where
217    /// `edge.from == memory`). Returned in insertion order.
218    pub fn edges_from(&self, memory: SymbolId) -> impl Iterator<Item = &Edge> {
219        self.outgoing
220            .get(&memory)
221            .into_iter()
222            .flat_map(move |idxs| idxs.iter().map(move |&i| &self.edges[i]))
223    }
224
225    /// Iterator over edges entering `memory` (i.e. where
226    /// `edge.to == memory`). Returned in insertion order.
227    pub fn edges_to(&self, memory: SymbolId) -> impl Iterator<Item = &Edge> {
228        self.incoming
229            .get(&memory)
230            .into_iter()
231            .flat_map(move |idxs| idxs.iter().map(move |&i| &self.edges[i]))
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn m(n: u64) -> SymbolId {
240        SymbolId::new(n)
241    }
242
243    fn at(millis: u64) -> ClockTime {
244        ClockTime::try_from_millis(millis).expect("non-sentinel")
245    }
246
247    fn edge(kind: EdgeKind, from: u64, to: u64) -> Edge {
248        Edge {
249            kind,
250            from: m(from),
251            to: m(to),
252            at: at(1_000_000),
253        }
254    }
255
256    #[test]
257    fn empty_dag_has_zero_edges() {
258        let dag = SupersessionDag::new();
259        assert!(dag.is_empty());
260        assert_eq!(dag.len(), 0);
261        assert_eq!(dag.edges(), &[]);
262        assert!(dag.edges_from(m(0)).next().is_none());
263        assert!(dag.edges_to(m(0)).next().is_none());
264    }
265
266    #[test]
267    fn single_edge_adds_and_indexes() {
268        let mut dag = SupersessionDag::new();
269        let e = edge(EdgeKind::Supersedes, 1, 2);
270        dag.add_edge(e).expect("add");
271        assert_eq!(dag.len(), 1);
272        assert_eq!(dag.edges(), &[e]);
273        let out: Vec<_> = dag.edges_from(m(1)).copied().collect();
274        assert_eq!(out, vec![e]);
275        let inc: Vec<_> = dag.edges_to(m(2)).copied().collect();
276        assert_eq!(inc, vec![e]);
277        // Reverse queries are empty.
278        assert!(dag.edges_to(m(1)).next().is_none());
279        assert!(dag.edges_from(m(2)).next().is_none());
280    }
281
282    #[test]
283    fn self_edge_is_rejected() {
284        let mut dag = SupersessionDag::new();
285        let err = dag
286            .add_edge(edge(EdgeKind::Supersedes, 7, 7))
287            .expect_err("self edge");
288        assert_eq!(
289            err,
290            DagError::SelfEdge {
291                memory: m(7),
292                kind: EdgeKind::Supersedes
293            }
294        );
295        assert!(dag.is_empty(), "failed add must not mutate");
296    }
297
298    #[test]
299    fn direct_cycle_is_rejected() {
300        // 1 -> 2, then 2 -> 1 would form a 2-cycle.
301        let mut dag = SupersessionDag::new();
302        dag.add_edge(edge(EdgeKind::Supersedes, 1, 2))
303            .expect("first");
304        let err = dag
305            .add_edge(edge(EdgeKind::Supersedes, 2, 1))
306            .expect_err("cycle");
307        assert!(matches!(
308            err,
309            DagError::SupersessionCycle {
310                from, to, kind: EdgeKind::Supersedes
311            } if from == m(2) && to == m(1)
312        ));
313        assert_eq!(dag.len(), 1, "failed add must not mutate");
314    }
315
316    #[test]
317    fn indirect_cycle_across_kinds_is_rejected() {
318        // 1 -Supersedes-> 2 -Corrects-> 3 -StaleParent-> 1 is a cycle
319        // across three edge kinds. Acyclicity is checked over the
320        // union of all kinds.
321        let mut dag = SupersessionDag::new();
322        dag.add_edge(edge(EdgeKind::Supersedes, 1, 2)).expect("a");
323        dag.add_edge(edge(EdgeKind::Corrects, 2, 3)).expect("b");
324        let err = dag
325            .add_edge(edge(EdgeKind::StaleParent, 3, 1))
326            .expect_err("3-cycle across kinds");
327        assert!(matches!(err, DagError::SupersessionCycle { .. }));
328        assert_eq!(dag.len(), 2);
329    }
330
331    #[test]
332    fn dag_shaped_additions_all_succeed() {
333        // 1 -> 2, 1 -> 3, 2 -> 4, 3 -> 4 is a diamond: not a cycle.
334        let mut dag = SupersessionDag::new();
335        dag.add_edge(edge(EdgeKind::Supersedes, 1, 2)).expect("12");
336        dag.add_edge(edge(EdgeKind::Supersedes, 1, 3)).expect("13");
337        dag.add_edge(edge(EdgeKind::Supersedes, 2, 4)).expect("24");
338        dag.add_edge(edge(EdgeKind::Supersedes, 3, 4)).expect("34");
339        assert_eq!(dag.len(), 4);
340        // Two edges enter 4; two edges leave 1.
341        assert_eq!(dag.edges_to(m(4)).count(), 2);
342        assert_eq!(dag.edges_from(m(1)).count(), 2);
343    }
344
345    #[test]
346    fn edge_try_from_record_matches_every_edge_variant() {
347        use crate::canonical::{CanonicalRecord, EdgeRecord};
348
349        let r = EdgeRecord {
350            from: m(10),
351            to: m(11),
352            at: at(42),
353        };
354        let cases = [
355            (CanonicalRecord::Supersedes(r), EdgeKind::Supersedes),
356            (CanonicalRecord::Corrects(r), EdgeKind::Corrects),
357            (CanonicalRecord::StaleParent(r), EdgeKind::StaleParent),
358            (CanonicalRecord::Reconfirms(r), EdgeKind::Reconfirms),
359        ];
360        for (record, expected_kind) in cases {
361            let e = Edge::try_from_record(&record).expect("edge variant");
362            assert_eq!(e.kind, expected_kind);
363            assert_eq!(e.from, m(10));
364            assert_eq!(e.to, m(11));
365            assert_eq!(e.at, at(42));
366        }
367    }
368
369    #[test]
370    fn edge_try_from_record_rejects_non_edge_variants() {
371        use crate::canonical::{CanonicalRecord, CheckpointRecord};
372        let cp = CanonicalRecord::Checkpoint(CheckpointRecord {
373            episode_id: m(1),
374            at: at(1),
375            memory_count: 0,
376        });
377        assert!(Edge::try_from_record(&cp).is_none());
378    }
379
380    #[test]
381    fn failed_cycle_add_does_not_corrupt_indices() {
382        // Specifically check that the outgoing/incoming indices are
383        // not mutated on a rejected add — otherwise subsequent queries
384        // would dangle past the end of `edges`.
385        let mut dag = SupersessionDag::new();
386        dag.add_edge(edge(EdgeKind::Supersedes, 1, 2)).expect("a");
387        let _ = dag
388            .add_edge(edge(EdgeKind::Supersedes, 2, 1))
389            .expect_err("cycle");
390        assert_eq!(dag.len(), 1);
391        assert_eq!(dag.edges_from(m(1)).count(), 1);
392        assert_eq!(dag.edges_from(m(2)).count(), 0);
393        assert_eq!(dag.edges_to(m(1)).count(), 0);
394        assert_eq!(dag.edges_to(m(2)).count(), 1);
395    }
396}