1use std::collections::BTreeMap;
31
32use corescout_mirror::MirrorSnapshot;
33use corescout_represent::latent::{LatentCatalogue, LatentStateId};
34use serde::{Deserialize, Serialize};
35
36use crate::uncertainty::{Confidence, Interval};
37
38#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct Prediction {
41 pub row: usize,
42 pub col: usize,
43 pub interval: Interval,
45 pub baseline: f64,
47 pub confidence: f64,
49 pub horizon_ns: u64,
51}
52
53impl Prediction {
54 pub fn is_informative(&self) -> bool {
56 self.interval.is_known() && self.confidence > 0.1
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct StatePrediction {
63 pub current: Option<LatentStateId>,
64 pub candidates: Vec<(LatentStateId, f64)>,
66 pub confidence: f64,
68}
69
70impl StatePrediction {
71 pub fn most_likely(&self) -> Option<(LatentStateId, f64)> {
72 self.candidates.first().copied()
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78struct CellModel {
79 a: f64,
81 b: f64,
83 sum_x: f64,
85 sum_y: f64,
86 sum_xx: f64,
87 sum_xy: f64,
88 weight: f64,
89 last_value: Option<f64>,
95 last_delta: Option<f64>,
96 confidence: Confidence,
97}
98
99impl CellModel {
100 fn new() -> CellModel {
101 CellModel {
102 a: 0.0,
103 b: 0.0,
104 sum_x: 0.0,
105 sum_y: 0.0,
106 sum_xx: 0.0,
107 sum_xy: 0.0,
108 weight: 0.0,
109 last_value: None,
110 last_delta: None,
111 confidence: Confidence::default(),
112 }
113 }
114
115 fn observe(&mut self, value: f64, forgetting: f64) {
120 if !value.is_finite() {
121 self.last_value = None;
124 self.last_delta = None;
125 return;
126 }
127 let Some(previous) = self.last_value else {
128 self.last_value = Some(value);
129 return;
130 };
131
132 let delta = value - previous;
133 if let Some(previous_delta) = self.last_delta {
134 let (x, y) = (previous_delta, delta);
135 self.sum_x = self.sum_x * forgetting + x;
136 self.sum_y = self.sum_y * forgetting + y;
137 self.sum_xx = self.sum_xx * forgetting + x * x;
138 self.sum_xy = self.sum_xy * forgetting + x * y;
139 self.weight = self.weight * forgetting + 1.0;
140 self.refit();
141 }
142 self.last_delta = Some(delta);
143 self.last_value = Some(value);
144 }
145
146 fn refit(&mut self) {
147 if self.weight < 4.0 {
148 return;
149 }
150 let mean_x = self.sum_x / self.weight;
151 let mean_y = self.sum_y / self.weight;
152 let variance = self.sum_xx / self.weight - mean_x * mean_x;
153 let covariance = self.sum_xy / self.weight - mean_x * mean_y;
154 if variance.abs() <= 1e-12 {
155 self.a = 0.0;
158 self.b = mean_y;
159 return;
160 }
161 self.a = covariance / variance;
162 self.b = mean_y - self.a * mean_x;
163 if !self.a.is_finite() || self.a.abs() > 4.0 {
167 self.a = 0.0;
168 self.b = mean_y;
169 }
170 }
171
172 fn predict(&self) -> f64 {
174 let Some(last) = self.last_value else {
175 return f64::NAN;
176 };
177 let delta = match self.last_delta {
178 Some(previous) => self.a * previous + self.b,
179 None => self.b,
180 };
181 last + delta
182 }
183
184 fn baseline(&self) -> f64 {
186 self.last_value.unwrap_or(f64::NAN)
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct SelfModel {
193 #[serde(with = "corescout_core::serde_util::cell_map")]
194 cells: BTreeMap<(usize, usize), CellModel>,
195 forgetting: f64,
196 rows: usize,
197 cols: usize,
198 updates: u64,
200 scored: u64,
202 #[serde(with = "corescout_core::serde_util::cell_map")]
204 pending: BTreeMap<(usize, usize), (f64, f64)>,
205 interval_ns: u64,
207 last_monotonic_ns: u64,
208 epoch: Option<u64>,
209}
210
211impl Default for SelfModel {
212 fn default() -> Self {
213 SelfModel::new(0.995)
214 }
215}
216
217impl SelfModel {
218 pub fn new(forgetting: f64) -> SelfModel {
219 SelfModel {
220 cells: BTreeMap::new(),
221 forgetting: forgetting.clamp(0.5, 1.0),
222 rows: 0,
223 cols: 0,
224 updates: 0,
225 scored: 0,
226 pending: BTreeMap::new(),
227 interval_ns: 0,
228 last_monotonic_ns: 0,
229 epoch: None,
230 }
231 }
232
233 pub fn updates(&self) -> u64 {
234 self.updates
235 }
236
237 pub fn scored(&self) -> u64 {
238 self.scored
239 }
240
241 pub fn interval_ns(&self) -> u64 {
242 self.interval_ns
243 }
244
245 pub fn tracked_cells(&self) -> usize {
246 self.cells.len()
247 }
248
249 pub fn observe(&mut self, snapshot: &MirrorSnapshot) {
255 if self.epoch != Some(snapshot.epoch) {
256 self.cells.clear();
259 self.pending.clear();
260 self.epoch = Some(snapshot.epoch);
261 self.rows = snapshot.state.rows();
262 self.cols = snapshot.state.cols();
263 }
264
265 for ((row, col), (predicted, baseline)) in std::mem::take(&mut self.pending) {
267 let actual = snapshot.state.get(row, col);
268 if actual.is_finite() {
269 if let Some(model) = self.cells.get_mut(&(row, col)) {
270 model.confidence.observe(predicted, baseline, actual);
271 self.scored += 1;
272 }
273 }
274 }
275
276 for row in 0..self.rows {
278 for col in 0..self.cols {
279 let value = snapshot.state.get(row, col);
280 self.cells
281 .entry((row, col))
282 .or_insert_with(CellModel::new)
283 .observe(value, self.forgetting);
284 }
285 }
286
287 if self.last_monotonic_ns > 0 && snapshot.monotonic_ns > self.last_monotonic_ns {
288 let gap = snapshot.monotonic_ns - self.last_monotonic_ns;
289 self.interval_ns = if self.interval_ns == 0 {
290 gap
291 } else {
292 (self.interval_ns * 7 + gap) / 8
295 };
296 }
297 self.last_monotonic_ns = snapshot.monotonic_ns;
298 self.updates += 1;
299 }
300
301 pub fn predict_next(&mut self) -> Vec<Prediction> {
304 let mut out = Vec::new();
305 self.pending.clear();
306 for ((row, col), model) in self.cells.iter() {
307 let point = model.predict();
308 let baseline = model.baseline();
309 if !point.is_finite() {
310 continue;
311 }
312 self.pending.insert((*row, *col), (point, baseline));
313 out.push(Prediction {
314 row: *row,
315 col: *col,
316 interval: model.confidence.interval(point),
317 baseline,
318 confidence: model.confidence.score(),
319 horizon_ns: self.interval_ns,
320 });
321 }
322 out
323 }
324
325 pub fn predict_cell(&self, row: usize, col: usize) -> Option<Prediction> {
327 let model = self.cells.get(&(row, col))?;
328 let point = model.predict();
329 if !point.is_finite() {
330 return None;
331 }
332 Some(Prediction {
333 row,
334 col,
335 interval: model.confidence.interval(point),
336 baseline: model.baseline(),
337 confidence: model.confidence.score(),
338 horizon_ns: self.interval_ns,
339 })
340 }
341
342 pub fn skill(&self) -> f64 {
353 let mut skills: Vec<f64> = self
354 .cells
355 .values()
356 .filter(|m| m.confidence.samples() >= 10)
357 .map(|m| m.confidence.skill())
358 .filter(|s| s.is_finite())
359 .collect();
360 if skills.is_empty() {
361 return 0.0;
362 }
363 skills.sort_by(|a, b| a.total_cmp(b));
364 skills[skills.len() / 2]
365 }
366
367 pub fn cells_with_skill(&self, threshold: f64) -> Vec<(usize, usize, f64)> {
369 let mut out: Vec<(usize, usize, f64)> = self
370 .cells
371 .iter()
372 .filter(|(_, m)| m.confidence.samples() >= 10)
373 .map(|((row, col), m)| (*row, *col, m.confidence.skill()))
374 .filter(|(_, _, skill)| *skill > threshold)
375 .collect();
376 out.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
377 out
378 }
379
380 pub fn predict_state(&self, catalogue: &LatentCatalogue) -> StatePrediction {
383 let current = catalogue.current();
384 let Some(current) = current else {
385 return StatePrediction {
386 current: None,
387 candidates: Vec::new(),
388 confidence: 0.0,
389 };
390 };
391
392 let counts = catalogue.transition_counts();
393 let outgoing: Vec<(LatentStateId, u64)> = counts
394 .iter()
395 .filter(|((from, _), _)| *from == current)
396 .map(|((_, to), count)| (*to, *count))
397 .collect();
398 let total: u64 = outgoing.iter().map(|(_, c)| *c).sum();
399 if total == 0 {
400 return StatePrediction {
403 current: Some(current),
404 candidates: vec![(current, 1.0)],
405 confidence: 0.1,
406 };
407 }
408
409 let mut candidates: Vec<(LatentStateId, f64)> = outgoing
410 .into_iter()
411 .map(|(to, count)| (to, count as f64 / total as f64))
412 .collect();
413 candidates.sort_by(|a, b| {
414 b.1.partial_cmp(&a.1)
415 .unwrap_or(std::cmp::Ordering::Equal)
416 .then(a.0.cmp(&b.0))
417 });
418
419 let evidence = (total as f64 / 20.0).min(1.0);
423 let concentration = candidates.first().map(|(_, p)| *p).unwrap_or(0.0);
424 StatePrediction {
425 current: Some(current),
426 candidates,
427 confidence: (evidence * concentration).clamp(0.0, 1.0),
428 }
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use corescout_mirror::test_support::fixture;
436
437 fn series(count: u64) -> Vec<MirrorSnapshot> {
440 let mut state = 12345u64;
441 (0..count)
442 .map(|i| {
443 let mut snapshot = fixture();
444 snapshot.sequence = i;
445 snapshot.monotonic_ns = i * 100_000_000;
446 snapshot
447 .state
448 .set(2, 0, ((i as f64) * 0.25).sin() * 1000.0 + 3_000_000.0);
449 state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
450 let noise = ((state >> 33) as f64 / (1u64 << 31) as f64) - 0.5;
451 snapshot.state.set(3, 0, 800_000.0 + noise * 10_000.0);
452 snapshot
453 })
454 .collect()
455 }
456
457 fn train(model: &mut SelfModel, snapshots: &[MirrorSnapshot]) {
458 for snapshot in snapshots {
459 model.predict_next();
460 model.observe(snapshot);
461 }
462 }
463
464 #[test]
465 fn a_smooth_signal_is_predicted_better_than_the_baseline() {
466 let mut model = SelfModel::default();
467 train(&mut model, &series(300));
468 let prediction = model.predict_cell(2, 0).expect("a prediction for cell 2,0");
469 assert!(prediction.interval.is_known());
470 assert!(
471 prediction.confidence > 0.3,
472 "confidence was {}",
473 prediction.confidence
474 );
475 }
476
477 #[test]
478 fn noise_earns_no_confidence() {
479 let mut model = SelfModel::default();
482 train(&mut model, &series(300));
483 let noisy = model.predict_cell(3, 0).expect("a prediction for cell 3,0");
484 let smooth = model.predict_cell(2, 0).expect("a prediction for cell 2,0");
485 assert!(
486 noisy.confidence < smooth.confidence,
487 "noise {} should not be as trusted as signal {}",
488 noisy.confidence,
489 smooth.confidence
490 );
491 }
492
493 #[test]
494 fn predictions_are_scored_against_what_actually_happened() {
495 let mut model = SelfModel::default();
496 train(&mut model, &series(100));
497 assert!(model.scored() > 50, "scored {}", model.scored());
498 assert!(model.updates() == 100);
499 }
500
501 #[test]
502 fn the_model_learns_the_cadence_from_the_reflections() {
503 let mut model = SelfModel::default();
504 train(&mut model, &series(50));
505 assert!(
506 (model.interval_ns() as i64 - 100_000_000).abs() < 5_000_000,
507 "learned interval {}",
508 model.interval_ns()
509 );
510 }
511
512 #[test]
513 fn an_epoch_change_discards_what_was_learned() {
514 let mut model = SelfModel::default();
517 train(&mut model, &series(100));
518 assert!(model.tracked_cells() > 0);
519
520 let mut changed = fixture();
521 changed.epoch += 1;
522 model.observe(&changed);
523 assert_eq!(model.scored(), model.scored(), "no scoring across the gap");
524 let prediction = model.predict_cell(2, 0);
526 assert!(
527 prediction.is_none() || prediction.unwrap().confidence == 0.0,
528 "confidence must not survive an epoch change"
529 );
530 }
531
532 #[test]
533 fn a_gap_breaks_the_delta_chain_rather_than_spanning_it() {
534 let mut model = SelfModel::default();
535 let mut snapshots = series(60);
536 snapshots[30].state.set(2, 0, f64::NAN);
538 train(&mut model, &snapshots);
539 assert!(model.predict_cell(2, 0).is_some());
541 }
542
543 #[test]
544 fn skill_is_reported_across_the_machine() {
545 let mut model = SelfModel::default();
546 train(&mut model, &series(300));
547 let skill = model.skill();
548 assert!(skill.is_finite());
549 let good = model.cells_with_skill(0.05);
550 assert!(
551 good.iter().any(|(row, col, _)| *row == 2 && *col == 0),
552 "the smooth cell should show skill: {good:?}"
553 );
554 }
555
556 #[test]
557 fn state_prediction_uses_the_catalogues_own_transitions() {
558 let mut catalogue = LatentCatalogue::new(0.75, 8);
559 for i in 0..60u64 {
560 let value = if i % 2 == 0 { 0.0 } else { 5.0 };
561 catalogue.observe(&[value, value], i * 1_000_000);
562 }
563 let model = SelfModel::default();
564 let prediction = model.predict_state(&catalogue);
565 assert!(prediction.current.is_some());
566 let (next, probability) = prediction.most_likely().expect("a successor");
567 assert_ne!(Some(next), prediction.current, "it always alternates");
568 assert!(probability > 0.9);
569 assert!(prediction.confidence > 0.5);
570 }
571
572 #[test]
573 fn an_empty_catalogue_yields_no_state_prediction() {
574 let model = SelfModel::default();
575 let prediction = model.predict_state(&LatentCatalogue::default());
576 assert!(prediction.current.is_none());
577 assert_eq!(prediction.confidence, 0.0);
578 }
579
580 #[test]
581 fn a_model_survives_serialisation() {
582 let mut model = SelfModel::default();
583 train(&mut model, &series(60));
584 let json = serde_json::to_string(&model).unwrap();
585 let back: SelfModel = serde_json::from_str(&json).unwrap();
586 assert_eq!(back.updates(), model.updates());
587 assert_eq!(back.tracked_cells(), model.tracked_cells());
588 }
589}