weavatrix_memory/analytics/
gaps.rs1use super::{GapKind, GapReport, MemoryAnalytics, ReasoningGap, ReasoningGapRequest};
2use crate::{EntityId, FactId, MemoryError, MemoryProjection, Result, project_graph};
3use std::collections::BTreeMap;
4
5impl MemoryAnalytics {
6 pub fn reasoning_gaps(
15 projection: &MemoryProjection,
16 request: ReasoningGapRequest,
17 ) -> Result<GapReport> {
18 validate_request(request)?;
19 let view = projection.view(request.clock);
20 let graph = project_graph(&view)?;
21 let mut gaps = Vec::new();
22 let support_counts = support_counts(&view.facts);
23 for node in &view.nodes {
24 let supports = support_counts.get(&node.id).copied().unwrap_or(0);
25 if node.kind.eq_ignore_ascii_case("decision") && supports == 0 {
26 gaps.push(gap(
27 &graph,
28 &node.id,
29 None,
30 GapKind::UnjustifiedDecision,
31 10_000,
32 "decision has no incoming support evidence",
33 ));
34 } else if node.kind.eq_ignore_ascii_case("inference")
35 && supports < request.minimum_supports
36 {
37 let severity = support_severity(supports, request.minimum_supports);
38 gaps.push(gap(
39 &graph,
40 &node.id,
41 None,
42 GapKind::SingleSourceInference,
43 severity,
44 "inference has fewer independent supports than required",
45 ));
46 }
47 }
48 add_fact_gaps(&mut gaps, &graph, &view.facts, request);
49 add_unstable_gaps(&mut gaps, &graph, projection, request);
50 gaps.sort_by(|left, right| {
51 right
52 .severity_bps
53 .cmp(&left.severity_bps)
54 .then_with(|| right.downstream_entities.cmp(&left.downstream_entities))
55 .then_with(|| left.entity.cmp(&right.entity))
56 .then_with(|| left.kind.cmp(&right.kind))
57 .then_with(|| left.fact.cmp(&right.fact))
58 });
59 let total = gaps.len();
60 gaps.truncate(request.max_results);
61 let analyzed_entities = view.nodes.len();
62 let health_bps = health(total, analyzed_entities);
63 Ok(GapReport {
64 gaps,
65 health_bps,
66 analyzed_entities,
67 analyzed_facts: view.facts.len(),
68 })
69 }
70}
71
72fn validate_request(request: ReasoningGapRequest) -> Result<()> {
73 if request.minimum_supports == 0
74 || request.low_confidence_bps > 10_000
75 || request.unstable_revision_count < 2
76 || request.stale_after_micros < 0
77 || request.max_results == 0
78 {
79 return Err(MemoryError::InvalidValue {
80 field: "reasoning_gap",
81 reason: "thresholds and limits must be in their documented ranges",
82 });
83 }
84 Ok(())
85}
86
87fn support_counts(facts: &[crate::MemoryFact]) -> BTreeMap<EntityId, usize> {
88 let mut counts = BTreeMap::new();
89 for fact in facts {
90 if matches!(
91 fact.relation.to_ascii_lowercase().as_str(),
92 "supports" | "supported_by" | "caused_by"
93 ) {
94 *counts.entry(fact.target.clone()).or_insert(0) += 1;
95 }
96 }
97 counts
98}
99
100fn add_fact_gaps(
101 gaps: &mut Vec<ReasoningGap>,
102 graph: &weavatrix_graph::Graph,
103 facts: &[crate::MemoryFact],
104 request: ReasoningGapRequest,
105) {
106 for fact in facts {
107 let confidence = fact.confidence.basis_points();
108 if confidence < request.low_confidence_bps {
109 gaps.push(gap(
110 graph,
111 &fact.target,
112 Some(fact.id.clone()),
113 GapKind::LowConfidenceFoundation,
114 10_000 - confidence,
115 "active fact confidence is below the requested floor",
116 ));
117 }
118 let age = request
119 .clock
120 .known_at
121 .as_unix_micros()
122 .saturating_sub(fact.recorded_at.as_unix_micros());
123 if age >= request.stale_after_micros {
124 let severity = stale_severity(age, request.stale_after_micros);
125 gaps.push(gap(
126 graph,
127 &fact.target,
128 Some(fact.id.clone()),
129 GapKind::StaleEvidence,
130 severity,
131 "active fact has not been refreshed within the requested interval",
132 ));
133 }
134 }
135}
136
137fn add_unstable_gaps(
138 gaps: &mut Vec<ReasoningGap>,
139 graph: &weavatrix_graph::Graph,
140 projection: &MemoryProjection,
141 request: ReasoningGapRequest,
142) {
143 let mut revisions = BTreeMap::<(EntityId, String), Vec<&crate::MemoryFact>>::new();
144 for fact in projection
145 .all_facts()
146 .iter()
147 .filter(|fact| fact.recorded_at <= request.clock.known_at)
148 {
149 revisions
150 .entry((fact.source.clone(), fact.relation.clone()))
151 .or_default()
152 .push(fact);
153 }
154 for ((entity, _), mut facts) in revisions {
155 if facts.len() < request.unstable_revision_count {
156 continue;
157 }
158 facts.sort_by_key(|fact| (fact.recorded_at, fact.id.clone()));
159 let superseding = facts
160 .iter()
161 .filter(|fact| fact.supersedes.is_some())
162 .count();
163 if superseding + 1 < request.unstable_revision_count {
164 continue;
165 }
166 gaps.push(gap(
167 graph,
168 &entity,
169 facts.last().map(|fact| fact.id.clone()),
170 GapKind::UnstableKnowledge,
171 revision_severity(facts.len()),
172 "belief has a long explicit supersession history",
173 ));
174 }
175}
176
177fn gap(
178 graph: &weavatrix_graph::Graph,
179 entity: &EntityId,
180 fact: Option<FactId>,
181 kind: GapKind,
182 severity_bps: u16,
183 explanation: &str,
184) -> ReasoningGap {
185 ReasoningGap {
186 entity: entity.clone(),
187 fact,
188 kind,
189 severity_bps,
190 downstream_entities: downstream(graph, entity),
191 explanation: explanation.to_owned(),
192 }
193}
194
195fn downstream(graph: &weavatrix_graph::Graph, entity: &EntityId) -> usize {
196 graph.node_index(entity.as_str()).map_or(0, |index| {
197 weavatrix_graph::bfs(graph, index).len().saturating_sub(1)
198 })
199}
200
201fn support_severity(actual: usize, required: usize) -> u16 {
202 let missing = required.saturating_sub(actual);
203 u16::try_from((missing.saturating_mul(10_000) / required).min(10_000)).unwrap_or(10_000)
204}
205
206fn stale_severity(age: i64, threshold: i64) -> u16 {
207 if threshold == 0 {
208 return 10_000;
209 }
210 let ratio = age.saturating_mul(5_000).saturating_div(threshold);
211 u16::try_from(ratio.clamp(1_000, 10_000)).unwrap_or(10_000)
212}
213
214fn revision_severity(count: usize) -> u16 {
215 u16::try_from(count.saturating_mul(2_000).min(10_000)).unwrap_or(10_000)
216}
217
218fn health(gaps: usize, entities: usize) -> u16 {
219 if entities == 0 {
220 return 10_000;
221 }
222 let penalty = gaps.saturating_mul(10_000).saturating_div(entities);
223 u16::try_from(10_000_usize.saturating_sub(penalty.min(10_000))).unwrap_or(0)
224}