Skip to main content

corescout_selfmodel/
predictor.rs

1//! An online self-model: predict the next reflection, then find out.
2//!
3//! # Shape
4//!
5//! One small autoregressive model per cell, fitted online, plus a running
6//! record of how well each has been doing. Deliberately the simplest thing that
7//! can express momentum:
8//!
9//! ```text
10//! delta_hat(t+1) = a * delta(t) + b
11//! ```
12//!
13//! The point of this milestone is not a good predictor. It is to find out
14//! whether the reflection contains enough signal for *any* predictor to beat
15//! copying the last value. A weak model that clearly beats the baseline is a
16//! stronger result than a complicated one whose advantage cannot be attributed.
17//!
18//! Fitting is online, by recursive least squares with a forgetting factor, so
19//! the model tracks a machine whose behaviour changes rather than averaging
20//! over an epoch that has ended.
21//!
22//! # Latent states as context
23//!
24//! [`SelfModel::predict_state`] answers the other question: given where the
25//! machine is now, where will it be. That is a distribution over discovered
26//! states rather than a number, and it is what makes a latent state *useful*
27//! rather than merely present: a state that improves this prediction has earned
28//! its place in the ontology.
29
30use 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/// A prediction about one cell.
39#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct Prediction {
41    pub row: usize,
42    pub col: usize,
43    /// The predicted value with its interval.
44    pub interval: Interval,
45    /// What the naive baseline predicts, for comparison.
46    pub baseline: f64,
47    /// Confidence in `0.0 ..= 1.0`.
48    pub confidence: f64,
49    /// How far ahead this reaches.
50    pub horizon_ns: u64,
51}
52
53impl Prediction {
54    /// Whether this prediction says anything the baseline does not.
55    pub fn is_informative(&self) -> bool {
56        self.interval.is_known() && self.confidence > 0.1
57    }
58}
59
60/// A prediction about which latent state comes next.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct StatePrediction {
63    pub current: Option<LatentStateId>,
64    /// Candidate successors with probabilities, most likely first.
65    pub candidates: Vec<(LatentStateId, f64)>,
66    /// Confidence in the whole distribution.
67    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/// One cell's fitted step, plus how well it has been doing.
77#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78struct CellModel {
79    /// Coefficient on the previous delta.
80    a: f64,
81    /// Intercept.
82    b: f64,
83    /// Running sums for recursive least squares.
84    sum_x: f64,
85    sum_y: f64,
86    sum_xx: f64,
87    sum_xy: f64,
88    weight: f64,
89    /// Last observed value and delta, so the next prediction has an input.
90    ///
91    /// `Option` rather than a `NaN` sentinel: these are genuinely "not yet
92    /// known", the distinction matters to every read, and it makes the model
93    /// serialisable without a codec.
94    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    /// Fold in a new observation, refitting.
116    ///
117    /// `forgetting` decays the accumulated sums so the fit tracks recent
118    /// behaviour. 0.995 gives a memory of a few hundred samples.
119    fn observe(&mut self, value: f64, forgetting: f64) {
120        if !value.is_finite() {
121            // A gap breaks the delta chain: differencing across a hole is not
122            // a measurement.
123            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            // Nothing to regress on: predict the mean change, which is the
156            // right answer for a steady counter.
157            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        // A runaway coefficient means the fit has gone unstable, usually
164        // because the machine changed character. Clamping keeps one bad window
165        // from producing a wild prediction.
166        if !self.a.is_finite() || self.a.abs() > 4.0 {
167            self.a = 0.0;
168            self.b = mean_y;
169        }
170    }
171
172    /// The predicted next value, or `NaN` when there is nothing to go on.
173    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    /// What "assume nothing changed" predicts.
185    fn baseline(&self) -> f64 {
186        self.last_value.unwrap_or(f64::NAN)
187    }
188}
189
190/// The machine's model of its own dynamics.
191#[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    /// Reflections folded in.
199    updates: u64,
200    /// Predictions made and later scored.
201    scored: u64,
202    /// The last prediction made, kept so the next reflection can score it.
203    #[serde(with = "corescout_core::serde_util::cell_map")]
204    pending: BTreeMap<(usize, usize), (f64, f64)>,
205    /// Cadence, learned from the reflections themselves.
206    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    /// Take in a reflection: score the last prediction, then update.
250    ///
251    /// Scoring before updating matters. A model that updated first would be
252    /// grading itself on data it had already seen, which is the most common way
253    /// to accidentally report excellent predictive performance.
254    pub fn observe(&mut self, snapshot: &MirrorSnapshot) {
255        if self.epoch != Some(snapshot.epoch) {
256            // The rows mean something different now. Everything learned about
257            // cell (12, 3) describes hardware that may no longer be there.
258            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        // 1. Score what was predicted last time.
266        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        // 2. Learn from it.
277        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                // A slow average, so one late tick does not redefine the
293                // model's idea of how far ahead it is predicting.
294                (self.interval_ns * 7 + gap) / 8
295            };
296        }
297        self.last_monotonic_ns = snapshot.monotonic_ns;
298        self.updates += 1;
299    }
300
301    /// Predict every cell's next value, and remember the predictions so the
302    /// next reflection can score them.
303    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    /// Predict one cell without recording it for scoring.
326    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    /// Typical skill across every cell that has been scored enough to judge.
343    ///
344    /// The headline number: how much better than "nothing changed" this model
345    /// is, on this machine, right now.
346    ///
347    /// # Median, not mean
348    ///
349    /// A machine has cells of wildly different scales, and a mean lets one
350    /// pathological cell decide the figure for all of them. The median says
351    /// what a typical cell does, which is what the question is actually asking.
352    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    /// Cells the model predicts meaningfully better than the baseline.
368    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    /// Predict which latent state comes next, from the catalogue's own
381    /// transition history.
382    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            // Never observed leaving this state. The honest prediction is that
401            // it stays, with low confidence.
402            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        // Confidence rises with evidence and with how concentrated the
420        // distribution is. A state that leads equally to five others is not
421        // predicted just because we know its options.
422        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    /// A series where one cell follows a smooth curve the model can learn, and
438    /// another is pure noise it cannot.
439    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        // The honesty check: a model must not claim skill on an unpredictable
480        // cell.
481        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        // Cell (12, 3) after a hotplug is different hardware; a model that kept
515        // its coefficients would be predicting one core from another's history.
516        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        // The cells are rebuilt from the new epoch, with no accumulated fit.
525        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        // Punch a hole.
537        snapshots[30].state.set(2, 0, f64::NAN);
538        train(&mut model, &snapshots);
539        // It survives, and still predicts.
540        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}