weavatrix_memory/analytics/
drift.rs1use 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 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}