Skip to main content

hikmah_kernel/
why.rs

1//! Provenance chains: why a memory looks the way it does.
2//!
3//! A trace's provenance chain is the inspectable answer to "why does this memory
4//! look like this": the trace itself with its provenance stamp, the supersession
5//! lineage that corrected or replaced it, and the unresolved conflicts standing
6//! beside it. No new plumbing: it reuses the ledger's supersession links and the
7//! claims module's conflict detection, the same data `hikmah recall` already
8//! carries per hit.
9
10use crate::claims::conflicting_ids;
11use crate::ledger::MemoryStore;
12use crate::trace::{PrivacyClass, Trace, TraceStatus};
13use serde::{Deserialize, Serialize};
14use std::collections::HashSet;
15
16/// How far back a supersession chain is followed. A cycle or an absurdly long
17/// chain is a data bug, not a reason to loop forever.
18const MAX_CHAIN: usize = 64;
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct TraceLineage {
22    pub trace: Trace,
23    /// Predecessors via `supersedes`, newest first: the corrected beliefs this trace replaced.
24    pub predecessors: Vec<Trace>,
25    /// The trace that replaced this one, when a supersession applied.
26    pub successor: Option<Trace>,
27    /// Ids of active traces whose claims conflict with this one and are still unresolved.
28    /// Empty for non-active traces, mirroring `hikmah recall`.
29    pub conflicts: Vec<String>,
30}
31
32impl MemoryStore {
33    /// The full provenance chain of one trace: itself, its supersession lineage,
34    /// and its unresolved conflicts. `None` when no trace carries `id`.
35    pub fn provenance_chain(&self, id: &str) -> Option<TraceLineage> {
36        let entry = self.get(id)?;
37        let trace = entry.trace.clone();
38        let active = entry.status == TraceStatus::Active;
39
40        let mut predecessors = Vec::new();
41        let mut seen = HashSet::new();
42        seen.insert(trace.id.clone());
43        let mut cursor = trace.supersedes.clone();
44        while let Some(old_id) = cursor {
45            if predecessors.len() >= MAX_CHAIN || !seen.insert(old_id.clone()) {
46                break;
47            }
48            let Some(old) = self.get(&old_id) else {
49                break;
50            };
51            cursor = old.trace.supersedes.clone();
52            predecessors.push(old.trace.clone());
53        }
54
55        // The successor is the trace whose supersession replaced this one. A purged
56        // successor, or one hidden by the privacy filter, is not shown: its id would
57        // point at something recall itself refuses to return.
58        let allow_sensitive = self.policy().allow_sensitive_persistence;
59        let successor = self
60            .all()
61            .filter(|e| e.status != TraceStatus::Purged)
62            .filter(|e| allow_sensitive || e.trace.privacy != PrivacyClass::Sensitive)
63            .filter(|e| e.trace.supersedes.as_deref() == Some(id))
64            .map(|e| e.trace.clone())
65            .next();
66
67        let conflicts = if active {
68            conflicting_ids(&trace, self.active_traces())
69        } else {
70            Vec::new()
71        };
72
73        Some(TraceLineage {
74            trace,
75            predecessors,
76            successor,
77            conflicts,
78        })
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::policy::KernelPolicy;
86    use crate::trace::{Trace, TraceKind};
87    use std::ops::{Deref, DerefMut};
88    use std::path::PathBuf;
89    use std::sync::atomic::{AtomicU64, Ordering};
90    use std::time::{SystemTime, UNIX_EPOCH};
91
92    static COUNTER: AtomicU64 = AtomicU64::new(0);
93
94    /// A scratch [`MemoryStore`] in a temp dir unique to this call: process id,
95    /// wall clock, and counter. Parallel tests in one run and leftover state
96    /// from previous runs can never share a store. The dir is removed when the
97    /// test ends, even on panic.
98    struct TestStore {
99        dir: PathBuf,
100        store: MemoryStore,
101    }
102
103    impl TestStore {
104        fn open() -> Self {
105            let nonce = SystemTime::now()
106                .duration_since(UNIX_EPOCH)
107                .unwrap()
108                .as_nanos();
109            let n = COUNTER.fetch_add(1, Ordering::SeqCst);
110            let dir = std::env::temp_dir().join(format!(
111                "hikmah-why-test-{}-{nonce}-{n}",
112                std::process::id()
113            ));
114            std::fs::create_dir_all(&dir).expect("mkdir");
115            let store =
116                MemoryStore::open(dir.join("memory.jsonl"), KernelPolicy::default()).expect("open");
117            TestStore { dir, store }
118        }
119    }
120
121    impl Drop for TestStore {
122        fn drop(&mut self) {
123            let _ = std::fs::remove_dir_all(&self.dir);
124        }
125    }
126
127    impl Deref for TestStore {
128        type Target = MemoryStore;
129        fn deref(&self) -> &MemoryStore {
130            &self.store
131        }
132    }
133
134    impl DerefMut for TestStore {
135        fn deref_mut(&mut self) -> &mut MemoryStore {
136            &mut self.store
137        }
138    }
139
140    fn open_store() -> TestStore {
141        TestStore::open()
142    }
143
144    fn add(store: &mut MemoryStore, content: &str) -> Trace {
145        store
146            .remember(Trace::new(TraceKind::Belief, content, "human:a"))
147            .expect("remember")
148            .0
149    }
150
151    #[test]
152    fn chain_follows_supersession_predecessors() {
153        let mut store = open_store();
154        let old = add(&mut store, "old belief");
155        let mut corrected = Trace::new(TraceKind::Belief, "corrected belief", "human:a");
156        corrected.supersedes = Some(old.id.clone());
157        let new = store.remember(corrected).expect("remember").0;
158
159        let chain = store.provenance_chain(&new.id).expect("chain");
160        assert_eq!(chain.trace.id, new.id);
161        assert_eq!(chain.predecessors.len(), 1);
162        assert_eq!(chain.predecessors[0].id, old.id);
163        assert!(chain.successor.is_none());
164        assert!(chain.conflicts.is_empty());
165    }
166
167    #[test]
168    fn chain_reports_the_successor() {
169        let mut store = open_store();
170        let old = add(&mut store, "old belief");
171        let mut corrected = Trace::new(TraceKind::Belief, "corrected belief", "human:a");
172        corrected.supersedes = Some(old.id.clone());
173        store.remember(corrected).expect("remember");
174
175        let chain = store.provenance_chain(&old.id).expect("chain");
176        assert!(chain.predecessors.is_empty());
177        let successor = chain.successor.expect("successor");
178        assert_eq!(successor.supersedes.as_deref(), Some(old.id.as_str()));
179    }
180
181    #[test]
182    fn chain_lists_unresolved_conflicts() {
183        let mut store = open_store();
184        let mut a = Trace::new(TraceKind::Belief, "the sky is green", "human:a");
185        a.claim_key = Some("sky-color".into());
186        a.claim_value = Some("green".into());
187        let a = store.remember(a).expect("remember").0;
188        let mut b = Trace::new(TraceKind::Belief, "the sky is blue", "human:b");
189        b.claim_key = Some("sky-color".into());
190        b.claim_value = Some("blue".into());
191        store.remember(b).expect("remember");
192
193        let chain = store.provenance_chain(&a.id).expect("chain");
194        assert_eq!(chain.conflicts.len(), 1);
195    }
196
197    #[test]
198    fn chain_is_none_for_unknown_id() {
199        let store = open_store();
200        assert!(store.provenance_chain("tr_missing").is_none());
201    }
202}