Skip to main content

khive_fold/
anchor.rs

1//! `Anchor` and `AnchorGraph`: in-memory causal provenance chains for credit assignment.
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::error::FoldError;
8
9/// A reference to an anchor (a source of truth for a claim or artifact).
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub struct AnchorRef {
13    /// Unique identifier for this anchor.
14    pub id: Uuid,
15    /// Kind of anchor: "paper", "book", "web", "record", "domain", "composite", ...
16    pub kind: String,
17    /// Optional stable identifier within the kind (e.g., DOI, ISBN, URL, record ID).
18    pub stable_id: Option<String>,
19}
20
21/// A graph of anchors, forming a causal chain.
22///
23/// Callers persist this across sessions. Brain navigates but doesn't own.
24#[derive(Debug, Clone, Default)]
25#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26pub struct AnchorGraph {
27    /// All anchor nodes in this graph.
28    pub nodes: Vec<AnchorRef>,
29    /// Edges: (from_id, to_id, relation) where relation is e.g. "derives_from", "uses", "contradicts".
30    pub edges: Vec<(Uuid, Uuid, String)>,
31}
32
33impl AnchorGraph {
34    /// Create an empty anchor graph.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Add a node to the graph.
40    pub fn add_node(&mut self, anchor: AnchorRef) {
41        self.nodes.push(anchor);
42    }
43
44    /// Add an edge to the graph.
45    pub fn add_edge(&mut self, from: Uuid, to: Uuid, relation: impl Into<String>) {
46        self.edges.push((from, to, relation.into()));
47    }
48
49    /// Find a node by its ID.
50    pub fn find_node(&self, id: Uuid) -> Option<&AnchorRef> {
51        self.nodes.iter().find(|n| n.id == id)
52    }
53
54    /// Get outgoing edges from a node.
55    pub fn outgoing(&self, from: Uuid) -> impl Iterator<Item = (Uuid, &str)> {
56        self.edges
57            .iter()
58            .filter(move |(f, _, _)| *f == from)
59            .map(|(_, to, rel)| (*to, rel.as_str()))
60    }
61
62    /// Get incoming edges to a node.
63    pub fn incoming(&self, to: Uuid) -> impl Iterator<Item = (Uuid, &str)> {
64        self.edges
65            .iter()
66            .filter(move |(_, t, _)| *t == to)
67            .map(|(from, _, rel)| (*from, rel.as_str()))
68    }
69}
70
71/// The Anchor primitive.
72pub trait Anchor: Send + Sync {
73    /// Trace the causal chain from a starting anchor to its sources.
74    fn trace(
75        &self,
76        graph: &AnchorGraph,
77        start: &AnchorRef,
78        max_depth: usize,
79    ) -> Result<Vec<AnchorRef>, FoldError>;
80
81    /// Reverse trace: given an outcome anchor, find the anchors that contributed.
82    fn credit(
83        &self,
84        graph: &AnchorGraph,
85        outcome: &AnchorRef,
86        max_depth: usize,
87    ) -> Result<Vec<(AnchorRef, f64)>, FoldError>;
88}
89
90/// A BFS-based anchor implementation.
91///
92/// Traces the causal chain by following forward edges (for `trace`) or
93/// backward edges (for `credit`) up to `max_depth` hops.
94#[derive(Debug, Clone, Copy, Default)]
95pub struct BfsAnchor;
96
97impl Anchor for BfsAnchor {
98    fn trace(
99        &self,
100        graph: &AnchorGraph,
101        start: &AnchorRef,
102        max_depth: usize,
103    ) -> Result<Vec<AnchorRef>, FoldError> {
104        if graph.find_node(start.id).is_none() {
105            return Err(FoldError::AnchorNotFound(start.id.to_string()));
106        }
107
108        let mut visited = std::collections::HashSet::new();
109        let mut result = Vec::new();
110        let mut queue = std::collections::VecDeque::new();
111
112        visited.insert(start.id);
113        queue.push_back((start.id, 0usize));
114
115        while let Some((current_id, depth)) = queue.pop_front() {
116            if let Some(node) = graph.find_node(current_id) {
117                if current_id != start.id {
118                    result.push(node.clone());
119                }
120
121                if depth < max_depth {
122                    for (next_id, _rel) in graph.outgoing(current_id) {
123                        if visited.insert(next_id) {
124                            queue.push_back((next_id, depth + 1));
125                        }
126                    }
127                }
128            }
129        }
130
131        Ok(result)
132    }
133
134    fn credit(
135        &self,
136        graph: &AnchorGraph,
137        outcome: &AnchorRef,
138        max_depth: usize,
139    ) -> Result<Vec<(AnchorRef, f64)>, FoldError> {
140        if graph.find_node(outcome.id).is_none() {
141            return Err(FoldError::AnchorNotFound(outcome.id.to_string()));
142        }
143
144        let mut visited = std::collections::HashSet::new();
145        let mut result = Vec::new();
146        let mut queue = std::collections::VecDeque::new();
147
148        visited.insert(outcome.id);
149        queue.push_back((outcome.id, 0usize, 1.0f64));
150
151        while let Some((current_id, depth, weight)) = queue.pop_front() {
152            if current_id != outcome.id {
153                if let Some(node) = graph.find_node(current_id) {
154                    result.push((node.clone(), weight));
155                }
156            }
157
158            if depth < max_depth {
159                let predecessors: Vec<(Uuid, f64)> = graph
160                    .incoming(current_id)
161                    .filter(|(id, _)| visited.insert(*id))
162                    .map(|(id, _)| (id, weight * 0.5))
163                    .collect();
164
165                for (pred_id, pred_weight) in predecessors {
166                    queue.push_back((pred_id, depth + 1, pred_weight));
167                }
168            }
169        }
170
171        Ok(result)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn make_ref(id: u128, kind: &str) -> AnchorRef {
180        AnchorRef {
181            id: Uuid::from_u128(id),
182            kind: kind.to_string(),
183            stable_id: None,
184        }
185    }
186
187    #[test]
188    fn test_anchor_ref_fields() {
189        let r = AnchorRef {
190            id: Uuid::new_v4(),
191            kind: "paper".into(),
192            stable_id: Some("doi:10.1234/x".into()),
193        };
194        assert_eq!(r.kind, "paper");
195        assert!(r.stable_id.is_some());
196    }
197
198    #[test]
199    fn test_anchor_graph_add_and_find() {
200        let mut graph = AnchorGraph::new();
201        let a = make_ref(1, "record");
202        let b = make_ref(2, "source");
203        graph.add_node(a.clone());
204        graph.add_node(b.clone());
205        graph.add_edge(a.id, b.id, "derives_from");
206
207        assert!(graph.find_node(a.id).is_some());
208        assert!(graph.find_node(Uuid::nil()).is_none());
209    }
210
211    #[test]
212    fn test_bfs_anchor_trace_not_found() {
213        let graph = AnchorGraph::new();
214        let unknown = make_ref(99, "unknown");
215        let err = BfsAnchor.trace(&graph, &unknown, 5).unwrap_err();
216        assert!(matches!(err, FoldError::AnchorNotFound(_)));
217    }
218
219    #[test]
220    fn test_bfs_anchor_trace_chain() {
221        let mut graph = AnchorGraph::new();
222        let a = make_ref(1, "record");
223        let b = make_ref(2, "source");
224        let c = make_ref(3, "paper");
225        graph.add_node(a.clone());
226        graph.add_node(b.clone());
227        graph.add_node(c.clone());
228        graph.add_edge(a.id, b.id, "derives_from");
229        graph.add_edge(b.id, c.id, "uses");
230
231        let chain = BfsAnchor.trace(&graph, &a, 5).unwrap();
232        assert_eq!(chain.len(), 2);
233        assert!(chain.iter().any(|r| r.id == b.id));
234        assert!(chain.iter().any(|r| r.id == c.id));
235    }
236
237    #[test]
238    fn test_bfs_anchor_trace_max_depth() {
239        let mut graph = AnchorGraph::new();
240        let nodes: Vec<AnchorRef> = (1..=5).map(|i| make_ref(i, "node")).collect();
241        for n in &nodes {
242            graph.add_node(n.clone());
243        }
244        for i in 0..4 {
245            graph.add_edge(nodes[i].id, nodes[i + 1].id, "next");
246        }
247
248        // With max_depth=1, only one hop from start
249        let chain = BfsAnchor.trace(&graph, &nodes[0], 1).unwrap();
250        assert_eq!(chain.len(), 1);
251        assert_eq!(chain[0].id, nodes[1].id);
252    }
253
254    #[test]
255    fn test_bfs_anchor_credit_not_found() {
256        let graph = AnchorGraph::new();
257        let unknown = make_ref(99, "unknown");
258        let err = BfsAnchor.credit(&graph, &unknown, 5).unwrap_err();
259        assert!(matches!(err, FoldError::AnchorNotFound(_)));
260    }
261
262    #[test]
263    fn test_bfs_anchor_credit_basic() {
264        let mut graph = AnchorGraph::new();
265        let source = make_ref(1, "paper");
266        let intermediate = make_ref(2, "record");
267        let outcome = make_ref(3, "composition");
268        graph.add_node(source.clone());
269        graph.add_node(intermediate.clone());
270        graph.add_node(outcome.clone());
271        // edges point forward: source → intermediate → outcome
272        graph.add_edge(source.id, intermediate.id, "uses");
273        graph.add_edge(intermediate.id, outcome.id, "derives_from");
274
275        let credits = BfsAnchor.credit(&graph, &outcome, 5).unwrap();
276        assert!(!credits.is_empty());
277        // intermediate should be credited with weight > 0
278        let inter_credit = credits.iter().find(|(r, _)| r.id == intermediate.id);
279        assert!(inter_credit.is_some());
280        assert!(inter_credit.unwrap().1 > 0.0f64);
281    }
282
283    #[test]
284    fn credit_weights_are_f64() {
285        let mut graph = AnchorGraph::new();
286        let source = make_ref(10, "source");
287        let outcome = make_ref(11, "outcome");
288        graph.add_node(source.clone());
289        graph.add_node(outcome.clone());
290        graph.add_edge(source.id, outcome.id, "causes");
291
292        let credits: Vec<(AnchorRef, f64)> = BfsAnchor.credit(&graph, &outcome, 2).unwrap();
293        assert!(!credits.is_empty());
294        let w: f64 = credits[0].1;
295        assert!(w > 0.0f64 && w <= 1.0f64);
296    }
297}