Skip to main content

corescout_selfmodel/
anomaly.rs

1//! Noticing that something is unusual.
2//!
3//! # Surprise is prediction error
4//!
5//! An anomaly detector that needs its own model of normality would be a second,
6//! parallel self-model that could disagree with the first. Instead, surprise is
7//! defined as *how badly the existing self-model predicted this reflection*.
8//! One model, one notion of normal, and a detector that improves automatically
9//! as the model does.
10//!
11//! # Why this matters beyond alerting
12//!
13//! Surprise is the signal for curiosity. A controller with an exploration
14//! budget should spend it where its model is worst, and that is exactly what a
15//! high surprise score identifies. It is also the trigger for consolidating a
16//! new latent state: a machine that keeps being surprised in the same way is a
17//! machine encountering a condition it has no concept for.
18
19use std::collections::BTreeMap;
20
21use corescout_mirror::MirrorSnapshot;
22use serde::{Deserialize, Serialize};
23
24/// How surprising one reflection was.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Surprise {
27    pub sequence: u64,
28    pub monotonic_ns: u64,
29    /// Mean absolute prediction error, in units of the model's own recent
30    /// typical error. 1.0 means "as wrong as usual"; 5.0 means "five times
31    /// worse than this model is normally".
32    pub score: f64,
33    /// The cells that contributed most.
34    pub contributors: Vec<Anomaly>,
35    /// How much of the machine could be judged at all.
36    pub coverage: f64,
37}
38
39impl Surprise {
40    /// Whether this reflection is unusual enough to act on.
41    pub fn is_anomalous(&self, threshold: f64) -> bool {
42        self.score >= threshold && self.coverage > 0.1
43    }
44}
45
46/// One cell that behaved unexpectedly.
47#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
48pub struct Anomaly {
49    pub row: usize,
50    pub col: usize,
51    pub expected: f64,
52    pub observed: f64,
53    /// Error in units of this cell's typical error.
54    pub deviation: f64,
55}
56
57/// Watches for reflections the self-model did not see coming.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct AnomalyDetector {
60    /// Decaying typical absolute error per cell.
61    #[serde(with = "corescout_core::serde_util::cell_map")]
62    typical: BTreeMap<(usize, usize), f64>,
63    alpha: f64,
64    /// Decaying mean of the whole-machine surprise score, so "unusual" is
65    /// relative to how surprising this machine usually is.
66    baseline_score: f64,
67    observations: u64,
68    epoch: Option<u64>,
69}
70
71impl Default for AnomalyDetector {
72    fn default() -> Self {
73        AnomalyDetector::new(0.05)
74    }
75}
76
77impl AnomalyDetector {
78    pub fn new(alpha: f64) -> AnomalyDetector {
79        AnomalyDetector {
80            typical: BTreeMap::new(),
81            alpha: alpha.clamp(1e-4, 1.0),
82            baseline_score: 0.0,
83            observations: 0,
84            epoch: None,
85        }
86    }
87
88    pub fn observations(&self) -> u64 {
89        self.observations
90    }
91
92    /// Compare what was predicted against what arrived.
93    ///
94    /// `predictions` maps cells to their predicted values. Cells without a
95    /// prediction are skipped rather than counted as perfectly predicted, which
96    /// would make a model that predicts nothing look infallible.
97    pub fn assess(
98        &mut self,
99        snapshot: &MirrorSnapshot,
100        predictions: &BTreeMap<(usize, usize), f64>,
101    ) -> Surprise {
102        if self.epoch != Some(snapshot.epoch) {
103            self.typical.clear();
104            self.baseline_score = 0.0;
105            self.epoch = Some(snapshot.epoch);
106        }
107
108        let mut deviations: Vec<Anomaly> = Vec::new();
109        let mut total_deviation = 0.0;
110        let mut judged = 0usize;
111
112        for ((row, col), expected) in predictions {
113            let observed = snapshot.state.get(*row, *col);
114            if !observed.is_finite() || !expected.is_finite() {
115                continue;
116            }
117            let error = (observed - expected).abs();
118            let typical = self.typical.entry((*row, *col)).or_insert(error);
119            // Relative to this cell's own history, so a cell measured in
120            // billions and a cell measured in degrees are comparable.
121            let deviation = if *typical <= 1e-12 {
122                if error <= 1e-12 {
123                    0.0
124                } else {
125                    // The cell has always been perfectly predicted and now is
126                    // not. That is maximally surprising, and a ratio would be
127                    // an infinity.
128                    10.0
129                }
130            } else {
131                error / *typical
132            };
133            *typical += self.alpha * (error - *typical);
134
135            total_deviation += deviation;
136            judged += 1;
137            if deviation > 3.0 {
138                deviations.push(Anomaly {
139                    row: *row,
140                    col: *col,
141                    expected: *expected,
142                    observed,
143                    deviation,
144                });
145            }
146        }
147
148        let score = if judged == 0 {
149            0.0
150        } else {
151            total_deviation / judged as f64
152        };
153        if self.observations == 0 {
154            self.baseline_score = score;
155        } else {
156            self.baseline_score += self.alpha * (score - self.baseline_score);
157        }
158        self.observations += 1;
159
160        deviations.sort_by(|a, b| {
161            b.deviation
162                .partial_cmp(&a.deviation)
163                .unwrap_or(std::cmp::Ordering::Equal)
164        });
165        deviations.truncate(8);
166
167        let cells = (snapshot.state.rows() * snapshot.state.cols()).max(1);
168        Surprise {
169            sequence: snapshot.sequence,
170            monotonic_ns: snapshot.monotonic_ns,
171            score,
172            contributors: deviations,
173            coverage: judged as f64 / cells as f64,
174        }
175    }
176
177    /// How surprising this machine usually is, for calibrating a threshold.
178    pub fn baseline(&self) -> f64 {
179        self.baseline_score
180    }
181
182    /// A threshold that would flag roughly the most unusual reflections,
183    /// scaled to this machine rather than to a constant chosen in advance.
184    pub fn suggested_threshold(&self) -> f64 {
185        (self.baseline_score * 3.0).max(2.0)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use corescout_mirror::test_support::fixture;
193
194    fn predictions(value: f64) -> BTreeMap<(usize, usize), f64> {
195        [((2usize, 0usize), value)].into_iter().collect()
196    }
197
198    fn snapshot_with(value: f64, sequence: u64) -> MirrorSnapshot {
199        let mut snapshot = fixture();
200        snapshot.sequence = sequence;
201        snapshot.state.set(2, 0, value);
202        snapshot
203    }
204
205    #[test]
206    fn a_well_predicted_reflection_is_not_surprising() {
207        let mut detector = AnomalyDetector::new(0.2);
208        for i in 0..50u64 {
209            let surprise = detector.assess(&snapshot_with(100.0, i), &predictions(100.0));
210            assert!(surprise.score < 1.0, "score {}", surprise.score);
211        }
212    }
213
214    #[test]
215    fn a_sudden_departure_is_flagged() {
216        let mut detector = AnomalyDetector::new(0.2);
217        // Establish what "normally wrong" looks like.
218        for i in 0..50u64 {
219            detector.assess(&snapshot_with(101.0, i), &predictions(100.0));
220        }
221        let surprise = detector.assess(&snapshot_with(500.0, 51), &predictions(100.0));
222        assert!(
223            surprise.score > 10.0,
224            "a 400-unit miss on a cell usually off by 1 should stand out: {}",
225            surprise.score
226        );
227        assert!(surprise.is_anomalous(detector.suggested_threshold()));
228        assert_eq!(surprise.contributors.len(), 1);
229        assert_eq!(surprise.contributors[0].observed, 500.0);
230    }
231
232    #[test]
233    fn a_noisy_cell_does_not_cry_wolf() {
234        // The point of scaling by each cell's own typical error: a cell that is
235        // always wrong by a lot is not surprising when it is wrong by a lot.
236        let mut detector = AnomalyDetector::new(0.2);
237        let mut state = 99u64;
238        for i in 0..100u64 {
239            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
240            let noise = ((state >> 40) as f64 / (1u64 << 23) as f64) * 400.0;
241            let surprise = detector.assess(&snapshot_with(100.0 + noise, i), &predictions(100.0));
242            if i > 50 {
243                assert!(
244                    surprise.score < 6.0,
245                    "a habitually noisy cell should not keep alarming: {}",
246                    surprise.score
247                );
248            }
249        }
250    }
251
252    #[test]
253    fn cells_without_a_prediction_are_not_counted_as_correct() {
254        // A model that predicts nothing must not look infallible.
255        let mut detector = AnomalyDetector::new(0.2);
256        let surprise = detector.assess(&fixture(), &BTreeMap::new());
257        assert_eq!(surprise.score, 0.0);
258        assert_eq!(surprise.coverage, 0.0);
259        assert!(
260            !surprise.is_anomalous(1.0),
261            "zero coverage cannot be evidence of anything"
262        );
263    }
264
265    #[test]
266    fn an_epoch_change_resets_what_normal_means() {
267        let mut detector = AnomalyDetector::new(0.2);
268        for i in 0..50u64 {
269            detector.assess(&snapshot_with(100.0, i), &predictions(100.0));
270        }
271        let mut changed = snapshot_with(100.0, 51);
272        changed.epoch += 1;
273        detector.assess(&changed, &predictions(100.0));
274        assert_eq!(detector.baseline(), 0.0, "normality is epoch-scoped");
275    }
276
277    #[test]
278    fn the_threshold_adapts_to_how_surprising_this_machine_usually_is() {
279        let mut calm = AnomalyDetector::new(0.2);
280        for i in 0..50u64 {
281            calm.assess(&snapshot_with(100.0, i), &predictions(100.0));
282        }
283        assert!(calm.suggested_threshold() >= 2.0);
284    }
285}