Skip to main content

weavatrix_memory/analytics/
gaps.rs

1use super::{GapKind, GapReport, MemoryAnalytics, ReasoningGap, ReasoningGapRequest};
2use crate::{EntityId, FactId, MemoryError, MemoryProjection, Result, project_graph};
3use std::collections::BTreeMap;
4
5impl MemoryAnalytics {
6    /// Finds evidence, support, stability, and freshness gaps.
7    ///
8    /// Downstream impact is computed on the canonical directed graph and all
9    /// scores use deterministic basis points.
10    ///
11    /// # Errors
12    ///
13    /// Rejects invalid thresholds or graph projection failures.
14    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}