Skip to main content

weavatrix_memory/analytics/
drift.rs

1use super::{ChangeKind, DriftReport, DriftSnapshot, MemoryAnalytics};
2use crate::{
3    domain::MemoryFact,
4    error::{MemoryError, Result},
5    id::EntityId,
6    projection::MemoryProjection,
7    time::Timestamp,
8};
9
10impl MemoryAnalytics {
11    /// Reconstructs a source/relation belief timeline from immutable facts.
12    ///
13    /// # Errors
14    ///
15    /// Rejects an empty or whitespace-padded relation.
16    pub fn drift(
17        projection: &MemoryProjection,
18        source: &EntityId,
19        relation: &str,
20        known_at: Timestamp,
21    ) -> Result<DriftReport> {
22        if relation.is_empty() || relation.trim() != relation {
23            return Err(MemoryError::InvalidValue {
24                field: "drift.relation",
25                reason: "must be non-empty without surrounding whitespace",
26            });
27        }
28        let mut facts = projection
29            .all_facts()
30            .iter()
31            .filter(|fact| {
32                fact.source == *source && fact.relation == relation && fact.recorded_at <= known_at
33            })
34            .collect::<Vec<_>>();
35        facts.sort_by_key(|fact| (fact.recorded_at, fact.id.clone()));
36        let mut snapshots = Vec::with_capacity(facts.len());
37        for (index, fact) in facts.iter().enumerate() {
38            let change = index
39                .checked_sub(1)
40                .map_or(ChangeKind::Initial, |prior| classify(facts[prior], fact));
41            snapshots.push(DriftSnapshot {
42                fact: fact.id.clone(),
43                target: fact.target.clone(),
44                recorded_at: fact.recorded_at,
45                confidence_bps: fact.confidence.basis_points(),
46                change,
47            });
48        }
49        let correction_count = snapshots
50            .iter()
51            .filter(|snapshot| snapshot.change == ChangeKind::Corrected)
52            .count();
53        let stability_bps = stability(&snapshots, correction_count);
54        Ok(DriftReport {
55            source: source.clone(),
56            relation: relation.to_owned(),
57            snapshots,
58            correction_count,
59            stability_bps,
60            likely_to_change: correction_count >= 2 || stability_bps < 6_000,
61        })
62    }
63}
64
65fn classify(prior: &MemoryFact, current: &MemoryFact) -> ChangeKind {
66    if current.supersedes.as_ref() == Some(&prior.id) || current.target != prior.target {
67        ChangeKind::Corrected
68    } else if current.confidence > prior.confidence {
69        ChangeKind::Reinforced
70    } else if current.confidence < prior.confidence {
71        ChangeKind::Weakened
72    } else {
73        ChangeKind::Refined
74    }
75}
76
77fn stability(snapshots: &[DriftSnapshot], corrections: usize) -> u16 {
78    let transitions = snapshots.len().saturating_sub(1);
79    if transitions == 0 {
80        return 10_000;
81    }
82    let other = transitions.saturating_sub(corrections);
83    let penalty = corrections
84        .saturating_mul(6_000)
85        .saturating_add(other.saturating_mul(1_000))
86        .saturating_div(transitions)
87        .min(10_000);
88    u16::try_from(10_000_usize.saturating_sub(penalty)).unwrap_or(0)
89}