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