Skip to main content

mentedb_graph/
belief.rs

1//! Belief propagation through causal and supporting edges.
2
3use std::collections::VecDeque;
4
5use ahash::{HashMap, HashSet};
6use mentedb_core::edge::EdgeType;
7use mentedb_core::types::MemoryId;
8
9use crate::csr::CsrGraph;
10
11/// Configuration for belief propagation.
12#[derive(Debug, Clone)]
13pub struct PropagationConfig {
14    /// Maximum BFS depth for propagation (default: 5).
15    pub max_depth: usize,
16    /// Outgoing edge types the walk follows (default: `Caused` and
17    /// `Supports`, the dependency edges). Any other edge type is ignored.
18    ///
19    /// The dependency edges are the default because they are the only ones
20    /// whose meaning survives a change in the source's confidence: an effect
21    /// or a supported claim is only as believable as what it rests on.
22    /// `Contradicts` and `Supersedes` record a conflict that was already
23    /// resolved, and walking them from a memory that just lost a conflict
24    /// would move the memories it once contradicted or replaced on no new
25    /// evidence. They can be added back here explicitly.
26    pub edge_types: Vec<EdgeType>,
27    /// Smallest per memory change [`propagate_delta`] records or walks past
28    /// (default: 0.01). A damped change below this stops that branch.
29    pub min_delta: f32,
30    /// Most memories one [`propagate_delta`] call may change, root excluded
31    /// (default: 64). The walk stops once it has this many.
32    pub max_affected: usize,
33    /// Dampening factor for Caused edges (default: 0.9).
34    pub caused_dampening: f32,
35    /// Factor for Supports edges (default: 0.5).
36    pub supports_factor: f32,
37    /// Factor for Contradicts edges (default: 0.7).
38    pub contradicts_factor: f32,
39    /// Floor multiplier for Supersedes edges (default: 0.1).
40    pub supersedes_floor: f32,
41}
42
43impl Default for PropagationConfig {
44    fn default() -> Self {
45        Self {
46            max_depth: 5,
47            edge_types: vec![EdgeType::Caused, EdgeType::Supports],
48            min_delta: 0.01,
49            max_affected: 64,
50            caused_dampening: 0.9,
51            supports_factor: 0.5,
52            contradicts_factor: 0.7,
53            supersedes_floor: 0.1,
54        }
55    }
56}
57
58impl PropagationConfig {
59    /// The defaults with every belief carrying edge type followed (`Caused`,
60    /// `Supports`, `Contradicts`, `Supersedes`), the edge set of
61    /// [`propagate_update`].
62    pub fn all_edges() -> Self {
63        Self {
64            edge_types: vec![
65                EdgeType::Caused,
66                EdgeType::Supports,
67                EdgeType::Contradicts,
68                EdgeType::Supersedes,
69            ],
70            ..Self::default()
71        }
72    }
73}
74
75/// Propagate a confidence change through the graph, following every belief
76/// carrying edge type ([`PropagationConfig::all_edges`]).
77///
78/// Returns a list of (affected_node, new_confidence) pairs. The initial node
79/// is included with `new_confidence` as its updated value. The values are
80/// absolute and computed from an assumed baseline of 1.0 for each neighbor,
81/// not from the neighbors' stored confidences; the engine's write path uses
82/// [`propagate_delta`] instead.
83pub fn propagate_update(
84    graph: &CsrGraph,
85    changed_id: MemoryId,
86    new_confidence: f32,
87) -> Vec<(MemoryId, f32)> {
88    propagate_update_with_config(
89        graph,
90        changed_id,
91        new_confidence,
92        &PropagationConfig::all_edges(),
93    )
94}
95
96/// Propagate a change of `delta` in `root`'s confidence to the memories that
97/// depend on it, as damped relative changes.
98///
99/// Returns `(memory, delta)` pairs for the affected memories, root excluded:
100/// the caller owns the root's own confidence and has already set it. A
101/// neighbor reached over an edge of weight `w` moves by the source's delta
102/// times `w` (clamped to `[0, 1]`) times the edge type's factor:
103/// `caused_dampening` for `Caused`, `supports_factor` for `Supports`, and the
104/// negated `contradicts_factor` for `Contradicts` (less belief in a claim is
105/// more belief in its contradiction). `Supersedes` is never walked here: a
106/// superseded memory is closed by its validity window, and its confidence
107/// belongs to the supersession that closed it.
108///
109/// Only edge types in `config.edge_types` are followed. Each memory is
110/// changed once, by the first (shortest) path that reaches it; a change
111/// smaller than `config.min_delta` is dropped and its branch stops; the walk
112/// stops at `config.max_depth` hops and after `config.max_affected` memories.
113/// Pure graph work: the caller applies the deltas to stored confidences
114/// after releasing the graph lock.
115pub fn propagate_delta(
116    graph: &CsrGraph,
117    root: MemoryId,
118    delta: f32,
119    config: &PropagationConfig,
120) -> Vec<(MemoryId, f32)> {
121    let mut affected = Vec::new();
122    if graph.get_idx(root).is_none()
123        || !delta.is_finite()
124        || delta.abs() < config.min_delta
125        || config.max_affected == 0
126    {
127        return affected;
128    }
129
130    let mut visited = HashSet::default();
131    visited.insert(root);
132    let mut queue: VecDeque<(MemoryId, f32, usize)> = VecDeque::new();
133    queue.push_back((root, delta, 0));
134
135    while let Some((node, node_delta, depth)) = queue.pop_front() {
136        if depth >= config.max_depth {
137            continue;
138        }
139        for (neighbor, edge) in graph.outgoing(node) {
140            if visited.contains(&neighbor) || !config.edge_types.contains(&edge.edge_type) {
141                continue;
142            }
143            let factor = match edge.edge_type {
144                EdgeType::Caused => config.caused_dampening,
145                EdgeType::Supports => config.supports_factor,
146                EdgeType::Contradicts => -config.contradicts_factor,
147                _ => continue,
148            };
149            let child_delta = node_delta * edge.weight.clamp(0.0, 1.0) * factor;
150            if !child_delta.is_finite() || child_delta.abs() < config.min_delta {
151                continue;
152            }
153            visited.insert(neighbor);
154            affected.push((neighbor, child_delta));
155            if affected.len() >= config.max_affected {
156                return affected;
157            }
158            queue.push_back((neighbor, child_delta, depth + 1));
159        }
160    }
161    affected
162}
163
164/// Propagate a confidence change through the graph with custom configuration.
165pub fn propagate_update_with_config(
166    graph: &CsrGraph,
167    changed_id: MemoryId,
168    new_confidence: f32,
169    config: &PropagationConfig,
170) -> Vec<(MemoryId, f32)> {
171    let Some(_) = graph.get_idx(changed_id) else {
172        return Vec::new();
173    };
174
175    // Track computed confidences for each affected node
176    let mut confidences: HashMap<MemoryId, f32> = HashMap::default();
177    confidences.insert(changed_id, new_confidence);
178
179    // BFS queue: (node_id, confidence_at_node, current_depth)
180    let mut queue: VecDeque<(MemoryId, f32, usize)> = VecDeque::new();
181    queue.push_back((changed_id, new_confidence, 0));
182
183    let mut visited = HashSet::default();
184    visited.insert(changed_id);
185
186    while let Some((node, node_confidence, depth)) = queue.pop_front() {
187        if depth >= config.max_depth {
188            continue;
189        }
190
191        for (neighbor, edge) in graph.outgoing(node) {
192            if !config.edge_types.contains(&edge.edge_type) {
193                continue;
194            }
195            let new_conf = match edge.edge_type {
196                EdgeType::Caused => {
197                    // child confidence = parent confidence * edge weight * dampening
198                    node_confidence * edge.weight * config.caused_dampening
199                }
200                EdgeType::Supports => {
201                    // supported node confidence += delta * edge weight * factor
202                    let current = confidences.get(&neighbor).copied().unwrap_or(1.0);
203                    let delta = node_confidence - 1.0; // change from baseline
204                    (current + delta * edge.weight * config.supports_factor).clamp(0.0, 1.0)
205                }
206                EdgeType::Contradicts => {
207                    // contradicted node confidence -= delta * edge weight * factor
208                    let current = confidences.get(&neighbor).copied().unwrap_or(1.0);
209                    (current - node_confidence * edge.weight * config.contradicts_factor)
210                        .clamp(0.0, 1.0)
211                }
212                EdgeType::Supersedes => {
213                    // superseded node confidence = min(current, new * floor)
214                    let current = confidences.get(&neighbor).copied().unwrap_or(1.0);
215                    current.min(node_confidence * config.supersedes_floor)
216                }
217                // Other edge types don't propagate belief
218                _ => continue,
219            };
220
221            confidences.insert(neighbor, new_conf);
222            if visited.insert(neighbor) {
223                queue.push_back((neighbor, new_conf, depth + 1));
224            }
225        }
226    }
227
228    confidences.into_iter().collect()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use mentedb_core::edge::MemoryEdge;
235
236    fn make_edge(src: MemoryId, tgt: MemoryId, etype: EdgeType, weight: f32) -> MemoryEdge {
237        MemoryEdge {
238            source: src,
239            target: tgt,
240            edge_type: etype,
241            weight,
242            created_at: 1000,
243            valid_from: None,
244            valid_until: None,
245            label: None,
246        }
247    }
248
249    #[test]
250    fn test_caused_propagation() {
251        let mut g = CsrGraph::new();
252        let a = MemoryId::new();
253        let b = MemoryId::new();
254        g.add_edge(&make_edge(a, b, EdgeType::Caused, 1.0));
255
256        let result = propagate_update(&g, a, 0.5);
257        let map: HashMap<MemoryId, f32> = result.into_iter().collect();
258
259        assert!((map[&a] - 0.5).abs() < 0.001);
260        // b = 0.5 * 1.0 * 0.9 = 0.45
261        assert!((map[&b] - 0.45).abs() < 0.001);
262    }
263
264    #[test]
265    fn test_contradicts_propagation() {
266        let mut g = CsrGraph::new();
267        let a = MemoryId::new();
268        let b = MemoryId::new();
269        g.add_edge(&make_edge(a, b, EdgeType::Contradicts, 1.0));
270
271        let result = propagate_update(&g, a, 0.8);
272        let map: HashMap<MemoryId, f32> = result.into_iter().collect();
273
274        // b = max(0, 1.0 - 0.8 * 1.0 * 0.7) = 0.44
275        assert!((map[&b] - 0.44).abs() < 0.001);
276    }
277
278    #[test]
279    fn test_supersedes_propagation() {
280        let mut g = CsrGraph::new();
281        let a = MemoryId::new();
282        let b = MemoryId::new();
283        g.add_edge(&make_edge(a, b, EdgeType::Supersedes, 1.0));
284
285        let result = propagate_update(&g, a, 0.9);
286        let map: HashMap<MemoryId, f32> = result.into_iter().collect();
287
288        // b = min(1.0, 0.9 * 0.1) = 0.09
289        assert!((map[&b] - 0.09).abs() < 0.001);
290    }
291
292    #[test]
293    fn test_max_depth_limit() {
294        // Build a chain of 10 Caused edges; only first 5 should propagate
295        let mut g = CsrGraph::new();
296        let ids: Vec<MemoryId> = (0..10).map(|_| MemoryId::new()).collect();
297        for i in 0..9 {
298            g.add_edge(&make_edge(ids[i], ids[i + 1], EdgeType::Caused, 1.0));
299        }
300
301        let result = propagate_update(&g, ids[0], 1.0);
302        let map: HashMap<MemoryId, f32> = result.into_iter().collect();
303
304        // Should have at most 6 nodes (start + 5 depth levels)
305        assert!(map.len() <= 6);
306    }
307
308    fn delta_map(
309        g: &CsrGraph,
310        root: MemoryId,
311        delta: f32,
312        cfg: &PropagationConfig,
313    ) -> HashMap<MemoryId, f32> {
314        propagate_delta(g, root, delta, cfg).into_iter().collect()
315    }
316
317    #[test]
318    fn delta_damps_along_a_caused_chain_and_excludes_the_root() {
319        let mut g = CsrGraph::new();
320        let (a, b, c) = (MemoryId::new(), MemoryId::new(), MemoryId::new());
321        g.add_edge(&make_edge(a, b, EdgeType::Caused, 1.0));
322        g.add_edge(&make_edge(b, c, EdgeType::Caused, 1.0));
323        let map = delta_map(&g, a, -0.4, &PropagationConfig::default());
324        assert!(!map.contains_key(&a), "the root is the caller's to set");
325        assert!((map[&b] + 0.36).abs() < 1e-5);
326        assert!((map[&c] + 0.324).abs() < 1e-5);
327    }
328
329    #[test]
330    fn delta_default_edge_set_skips_conflict_edges() {
331        let mut g = CsrGraph::new();
332        let (a, x, y, s) = (
333            MemoryId::new(),
334            MemoryId::new(),
335            MemoryId::new(),
336            MemoryId::new(),
337        );
338        g.add_edge(&make_edge(a, x, EdgeType::Contradicts, 1.0));
339        g.add_edge(&make_edge(a, y, EdgeType::Supersedes, 1.0));
340        g.add_edge(&make_edge(a, s, EdgeType::Supports, 1.0));
341        let map = delta_map(&g, a, -0.4, &PropagationConfig::default());
342        assert_eq!(map.len(), 1);
343        assert!((map[&s] + 0.2).abs() < 1e-5);
344
345        // Opting Contradicts in inverts the change.
346        let map = delta_map(&g, a, -0.4, &PropagationConfig::all_edges());
347        assert!((map[&x] - 0.28).abs() < 1e-5);
348        assert!(
349            !map.contains_key(&y),
350            "Supersedes is never walked by deltas"
351        );
352    }
353
354    #[test]
355    fn delta_respects_min_delta_and_max_affected() {
356        let mut g = CsrGraph::new();
357        let root = MemoryId::new();
358        for _ in 0..10 {
359            g.add_edge(&make_edge(root, MemoryId::new(), EdgeType::Caused, 1.0));
360        }
361        let cfg = PropagationConfig {
362            max_affected: 3,
363            ..PropagationConfig::default()
364        };
365        assert_eq!(propagate_delta(&g, root, -0.5, &cfg).len(), 3);
366        let cfg = PropagationConfig {
367            min_delta: 0.5,
368            ..PropagationConfig::default()
369        };
370        // 0.5 * 0.9 = 0.45 is below the floor on every branch.
371        assert!(propagate_delta(&g, root, -0.5, &cfg).is_empty());
372    }
373
374    #[test]
375    fn test_unrelated_edges_dont_propagate() {
376        let mut g = CsrGraph::new();
377        let a = MemoryId::new();
378        let b = MemoryId::new();
379        g.add_edge(&make_edge(a, b, EdgeType::Related, 1.0));
380
381        let result = propagate_update(&g, a, 0.5);
382        // Only the changed node itself
383        assert_eq!(result.len(), 1);
384    }
385}