Skip to main content

weavatrix_memory/analytics/
gaps.rs

1use 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    /// Finds evidence, support, stability, and freshness gaps.
13    ///
14    /// Downstream impact is computed on the canonical directed graph and all
15    /// scores use deterministic basis points.
16    ///
17    /// # Errors
18    ///
19    /// Rejects invalid thresholds or graph projection failures.
20    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}