weavatrix_memory/analytics/
belief.rs1use super::{
2 BeliefRevisionReport, BeliefRevisionRequest, CascadeEffect, Contradiction, MemoryAnalytics,
3};
4use crate::{
5 domain::MemoryFact, error::Result, graph_projection::project_graph, id::EntityId,
6 projection::MemoryProjection,
7};
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9
10impl MemoryAnalytics {
11 pub fn belief_revision(
20 projection: &MemoryProjection,
21 request: &BeliefRevisionRequest,
22 ) -> Result<BeliefRevisionReport> {
23 request.hypothesis.validate()?;
24 let view = projection.view(request.clock);
25 let graph = project_graph(&view)?;
26 let mut contradictions = view
27 .facts
28 .iter()
29 .filter_map(|fact| contradiction(fact, &request.hypothesis))
30 .collect::<Vec<_>>();
31 contradictions.sort_by(|left, right| left.fact.cmp(&right.fact));
32 let roots = contradictions
33 .iter()
34 .filter_map(|item| {
35 view.facts
36 .iter()
37 .find(|fact| fact.id == item.fact)
38 .map(|fact| fact.target.clone())
39 })
40 .collect::<BTreeSet<_>>();
41 let cascade = cascade(
42 &graph,
43 roots,
44 request.max_depth,
45 request.hypothesis.confidence.basis_points(),
46 );
47 let kinds = view
48 .nodes
49 .iter()
50 .map(|node| (node.id.clone(), node.kind.as_str()))
51 .collect::<BTreeMap<_, _>>();
52 let invalidated_decisions = cascade
53 .iter()
54 .filter(|effect| {
55 kinds
56 .get(&effect.entity)
57 .is_some_and(|kind| kind.eq_ignore_ascii_case("decision"))
58 })
59 .map(|effect| effect.entity.clone())
60 .collect();
61 Ok(BeliefRevisionReport {
62 contradictions,
63 cascade,
64 invalidated_decisions,
65 })
66 }
67}
68
69fn contradiction(fact: &MemoryFact, hypothesis: &MemoryFact) -> Option<Contradiction> {
70 let competing = fact.source == hypothesis.source
71 && fact.relation == hypothesis.relation
72 && fact.target != hypothesis.target;
73 let explicit = fact.relation.eq_ignore_ascii_case("contradicts")
74 && ((fact.source == hypothesis.source && fact.target == hypothesis.target)
75 || (fact.source == hypothesis.target && fact.target == hypothesis.source));
76 let corrected = hypothesis.supersedes.as_ref() == Some(&fact.id);
77 if !(competing || explicit || corrected) {
78 return None;
79 }
80 let reason = if explicit {
81 "explicit contradicts relation"
82 } else if corrected {
83 "hypothesis explicitly supersedes this fact"
84 } else {
85 "same source and relation assert a different target"
86 };
87 Some(Contradiction {
88 fact: fact.id.clone(),
89 strength_bps: fact
90 .confidence
91 .basis_points()
92 .min(hypothesis.confidence.basis_points()),
93 reason: reason.to_owned(),
94 })
95}
96
97fn cascade(
98 graph: &weavatrix_graph::Graph,
99 roots: BTreeSet<EntityId>,
100 max_depth: usize,
101 hypothesis_confidence: u16,
102) -> Vec<CascadeEffect> {
103 let mut distances = BTreeMap::<EntityId, usize>::new();
104 let mut queue = VecDeque::new();
105 for root in roots {
106 distances.insert(root.clone(), 0);
107 queue.push_back(root);
108 }
109 while let Some(entity) = queue.pop_front() {
110 let depth = distances[&entity];
111 if depth >= max_depth {
112 continue;
113 }
114 let Some(index) = graph.node_index(entity.as_str()) else {
115 continue;
116 };
117 for neighbor in graph.outgoing_neighbors_at(index) {
118 let Some(node) = graph.node_at(neighbor) else {
119 continue;
120 };
121 let Ok(next) = EntityId::new(node.id.as_str()) else {
122 continue;
123 };
124 if !distances.contains_key(&next) {
125 distances.insert(next.clone(), depth + 1);
126 queue.push_back(next);
127 }
128 }
129 }
130 distances
131 .into_iter()
132 .map(|(entity, depth)| CascadeEffect {
133 entity,
134 depth,
135 revised_confidence_bps: revised_confidence(hypothesis_confidence, depth),
136 })
137 .collect()
138}
139
140fn revised_confidence(confidence: u16, depth: usize) -> u16 {
141 let divisor = u32::try_from(depth + 2).unwrap_or(u32::MAX);
142 let weakening = u32::from(confidence) / divisor;
143 u16::try_from(10_000_u32.saturating_sub(weakening)).unwrap_or(0)
144}