weavatrix_memory/analytics/
drift.rs1use super::{ChangeKind, DriftReport, DriftSnapshot, MemoryAnalytics};
2use crate::{EntityId, MemoryError, MemoryProjection, Result, Timestamp};
3
4impl MemoryAnalytics {
5 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}