1use std::collections::BTreeMap;
20
21use corescout_mirror::MirrorSnapshot;
22use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Surprise {
27 pub sequence: u64,
28 pub monotonic_ns: u64,
29 pub score: f64,
33 pub contributors: Vec<Anomaly>,
35 pub coverage: f64,
37}
38
39impl Surprise {
40 pub fn is_anomalous(&self, threshold: f64) -> bool {
42 self.score >= threshold && self.coverage > 0.1
43 }
44}
45
46#[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 pub deviation: f64,
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct AnomalyDetector {
60 #[serde(with = "corescout_core::serde_util::cell_map")]
62 typical: BTreeMap<(usize, usize), f64>,
63 alpha: f64,
64 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 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 let deviation = if *typical <= 1e-12 {
122 if error <= 1e-12 {
123 0.0
124 } else {
125 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 pub fn baseline(&self) -> f64 {
179 self.baseline_score
180 }
181
182 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 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 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 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}