Skip to main content

weavatrix_memory/analytics/
drift.rs

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