Skip to main content

corescout_selfmodel/
predict.rs

1//! Predicting the next reflection.
2//!
3//! ```text
4//! F(M(t)) -> M_hat(t+1)
5//! ```
6//!
7//! # Why the baselines matter more than the model
8//!
9//! Predicting a mirror snapshot is easy to do impressively badly. Most cells
10//! barely move between consecutive reflections, so *copying the last value*
11//! scores extremely well on almost every channel. Any model that does not
12//! clearly beat that has learned nothing, however small its absolute error.
13//!
14//! So every prediction here is scored as **skill relative to the best naive
15//! baseline**, and the baselines are chosen to be genuinely hard to beat:
16//!
17//! - **Persistence.** `x(t+1) = x(t)`. Very strong for slow-moving readings.
18//! - **Drift.** `x(t+1) = x(t) + mean recent delta`. Near-perfect for
19//!   accumulators, which otherwise make a model look brilliant for free.
20//!
21//! The drift baseline is the reason the accumulator question in
22//! `corescout_represent::discover` is not academic: an observer that has not worked out
23//! which variables accumulate cannot construct this baseline, and will mistake
24//! trivially predictable counters for evidence that it understands the machine.
25//!
26//! # The model
27//!
28//! A per-cell autoregressive step fitted by least squares:
29//!
30//! ```text
31//! delta(t+1) = a * delta(t) + b
32//! ```
33//!
34//! Deliberately the simplest thing that can express momentum. The point of this
35//! milestone is not a good predictor; it is to find out whether the reflection
36//! contains enough signal for *any* predictor to beat copying the last value.
37//! A weak model that clearly beats the baseline is a stronger result than a
38//! complicated one whose advantage cannot be attributed.
39
40use std::collections::BTreeMap;
41
42use corescout_represent::discover::{differences, mean};
43
44/// How well something predicted one cell.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct CellScore {
47    pub row: usize,
48    pub col: usize,
49    /// Mean absolute error of the model.
50    pub model_mae: f64,
51    /// Mean absolute error of the best naive baseline.
52    pub baseline_mae: f64,
53    /// `1 - model/baseline`. Positive means the model beat the baseline;
54    /// 0.0 means it merely matched it.
55    pub skill: f64,
56    pub samples: usize,
57}
58
59/// Prediction performance over a whole machine.
60#[derive(Debug, Clone, PartialEq)]
61pub struct PredictionReport {
62    /// Cells that could be scored at all.
63    pub cells: usize,
64    /// Cells where the model beat the baseline by a margin worth reporting.
65    pub cells_with_skill: usize,
66    /// Median skill across scored cells.
67    pub median_skill: f64,
68    /// Mean skill, which the tails move and the median does not.
69    pub mean_skill: f64,
70    /// The cells the model predicted best.
71    pub best: Vec<CellScore>,
72    /// Cells where the model was clearly worse than doing nothing clever.
73    pub worst: Vec<CellScore>,
74    /// Columns the predictor treated as accumulators.
75    pub accumulator_columns: Vec<usize>,
76}
77
78/// Fit and evaluate a one-step predictor over remembered series.
79///
80/// `accumulator_columns` is what the observer believes accumulates: either
81/// told by the mirror, or worked out from behaviour. It changes which baseline
82/// each cell is scored against, so getting it wrong is penalised.
83///
84/// The series are split in time: the first `train_fraction` fits, the rest
85/// scores. No cell is ever scored on a sample used to fit it.
86pub fn evaluate(
87    series: &BTreeMap<(usize, usize), Vec<f64>>,
88    accumulator_columns: &[usize],
89    train_fraction: f64,
90) -> PredictionReport {
91    let mut scores: Vec<CellScore> = Vec::new();
92
93    for ((row, col), values) in series {
94        let is_accumulator = accumulator_columns.contains(col);
95        if let Some(score) = score_cell(*row, *col, values, is_accumulator, train_fraction) {
96            scores.push(score);
97        }
98    }
99
100    let skills: Vec<f64> = scores.iter().map(|s| s.skill).collect();
101    let median_skill = median(&skills);
102    let mean_skill = mean(&skills);
103
104    let mut ranked = scores.clone();
105    ranked.sort_by(|a, b| {
106        b.skill
107            .partial_cmp(&a.skill)
108            .unwrap_or(std::cmp::Ordering::Equal)
109    });
110    let best: Vec<CellScore> = ranked.iter().take(5).copied().collect();
111    let worst: Vec<CellScore> = ranked.iter().rev().take(5).copied().collect();
112
113    let mut accumulator_columns = accumulator_columns.to_vec();
114    accumulator_columns.sort_unstable();
115
116    PredictionReport {
117        cells: scores.len(),
118        // A 1% margin: below that the difference is noise, and claiming skill
119        // for it would be the same kind of overreach the baselines exist to
120        // prevent.
121        cells_with_skill: scores.iter().filter(|s| s.skill > 0.01).count(),
122        median_skill,
123        mean_skill,
124        best,
125        worst,
126        accumulator_columns,
127    }
128}
129
130/// Score one cell, or `None` when there is not enough usable data.
131fn score_cell(
132    row: usize,
133    col: usize,
134    values: &[f64],
135    is_accumulator: bool,
136    train_fraction: f64,
137) -> Option<CellScore> {
138    // Work in deltas: it is the same information, and it makes the
139    // accumulator and reading cases the same shape of problem.
140    let deltas = differences(values);
141    if deltas.len() < 12 {
142        return None;
143    }
144    let split = ((deltas.len() as f64) * train_fraction) as usize;
145    if split < 6 || deltas.len() - split < 4 {
146        return None;
147    }
148
149    let train = &deltas[..split];
150    let test = &deltas[split..];
151
152    // Baseline. For an accumulator, the sensible naive guess is that it keeps
153    // going at its recent rate; for a reading, that it does not move at all.
154    let baseline_delta = if is_accumulator {
155        mean(
156            &train
157                .iter()
158                .copied()
159                .filter(|d| d.is_finite())
160                .collect::<Vec<f64>>(),
161        )
162    } else {
163        0.0
164    };
165
166    let (a, b) = fit_ar1(train)?;
167
168    let mut model_error = 0.0;
169    let mut baseline_error = 0.0;
170    let mut samples = 0usize;
171    let mut previous = train.last().copied().unwrap_or(0.0);
172
173    for actual in test {
174        if !actual.is_finite() {
175            previous = f64::NAN;
176            continue;
177        }
178        if previous.is_finite() {
179            let predicted = a * previous + b;
180            model_error += (predicted - actual).abs();
181            baseline_error += (baseline_delta - actual).abs();
182            samples += 1;
183        }
184        previous = *actual;
185    }
186
187    if samples < 4 {
188        return None;
189    }
190    let model_mae = model_error / samples as f64;
191    let baseline_mae = baseline_error / samples as f64;
192
193    // A cell that never moves is perfectly predicted by everything and
194    // distinguishes nothing, so it is not scored.
195    if baseline_mae <= 1e-12 && model_mae <= 1e-12 {
196        return None;
197    }
198    let skill = if baseline_mae > 1e-12 {
199        1.0 - (model_mae / baseline_mae)
200    } else {
201        // The baseline was perfect and the model was not.
202        -1.0
203    };
204
205    Some(CellScore {
206        row,
207        col,
208        model_mae,
209        baseline_mae,
210        skill: skill.clamp(-1.0, 1.0),
211        samples,
212    })
213}
214
215/// Least-squares fit of `y(t+1) = a*y(t) + b`.
216fn fit_ar1(series: &[f64]) -> Option<(f64, f64)> {
217    let pairs: Vec<(f64, f64)> = series
218        .windows(2)
219        .filter(|w| w[0].is_finite() && w[1].is_finite())
220        .map(|w| (w[0], w[1]))
221        .collect();
222    if pairs.len() < 4 {
223        return None;
224    }
225    let n = pairs.len() as f64;
226    let mean_x = pairs.iter().map(|(x, _)| *x).sum::<f64>() / n;
227    let mean_y = pairs.iter().map(|(_, y)| *y).sum::<f64>() / n;
228
229    let mut cov = 0.0;
230    let mut var = 0.0;
231    for (x, y) in &pairs {
232        cov += (x - mean_x) * (y - mean_y);
233        var += (x - mean_x).powi(2);
234    }
235    // No variation to regress on: fall back to predicting the mean, which is
236    // the correct answer for a constant series rather than a failure.
237    if var <= 1e-12 {
238        return Some((0.0, mean_y));
239    }
240    let a = cov / var;
241    Some((a, mean_y - a * mean_x))
242}
243
244fn median(values: &[f64]) -> f64 {
245    if values.is_empty() {
246        return 0.0;
247    }
248    let mut sorted: Vec<f64> = values.to_vec();
249    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
250    let mid = sorted.len() / 2;
251    if sorted.len() % 2 == 0 {
252        (sorted[mid - 1] + sorted[mid]) / 2.0
253    } else {
254        sorted[mid]
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn series(values: Vec<f64>) -> BTreeMap<(usize, usize), Vec<f64>> {
263        [((0usize, 0usize), values)].into_iter().collect()
264    }
265
266    #[test]
267    fn a_pure_counter_is_predicted_by_the_drift_baseline_not_by_skill() {
268        // The trap this whole module is arranged around. A counter rising by
269        // exactly 10 every tick is trivially predictable, and a model that
270        // "predicts" it has demonstrated nothing.
271        let values: Vec<f64> = (0..60).map(|i| (i as f64) * 10.0).collect();
272        let report = evaluate(&series(values), &[0], 0.7);
273        assert_eq!(
274            report.cells, 0,
275            "a perfectly steady counter is not scorable"
276        );
277    }
278
279    #[test]
280    fn momentum_is_learnable_and_shows_as_skill() {
281        // An oscillation the AR(1) step can follow but persistence cannot.
282        let values: Vec<f64> = (0..120)
283            .map(|i| ((i as f64) * 0.35).sin() * 100.0)
284            .collect();
285        let report = evaluate(&series(values), &[], 0.7);
286        assert_eq!(report.cells, 1);
287        assert!(
288            report.median_skill > 0.2,
289            "expected real skill on a smooth signal, got {}",
290            report.median_skill
291        );
292    }
293
294    #[test]
295    fn pure_noise_yields_no_skill() {
296        // The honesty check. On an unpredictable series the model must not
297        // appear to beat the baseline.
298        let mut state = 12345u64;
299        let values: Vec<f64> = (0..120)
300            .map(|_| {
301                state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
302                ((state >> 33) as f64 / (1u64 << 31) as f64) - 0.5
303            })
304            .collect();
305        let report = evaluate(&series(values), &[], 0.7);
306        assert!(
307            report.median_skill < 0.15,
308            "claimed skill {} on noise",
309            report.median_skill
310        );
311    }
312
313    #[test]
314    fn mislabelling_an_accumulator_costs_measurable_skill() {
315        // The direct measurement of what the `Cumulative` label is worth: a
316        // rising counter with a wobble, scored with and without knowing that
317        // it accumulates.
318        let values: Vec<f64> = (0..120)
319            .map(|i| (i as f64) * 10.0 + ((i as f64) * 0.7).sin() * 3.0)
320            .collect();
321
322        let knowing = evaluate(&series(values.clone()), &[0], 0.7);
323        let not_knowing = evaluate(&series(values), &[], 0.7);
324
325        assert_eq!(knowing.cells, 1);
326        assert_eq!(not_knowing.cells, 1);
327        // Not knowing means being scored against a much weaker baseline, so the
328        // model looks better while predicting exactly as well.
329        assert!(
330            not_knowing.median_skill > knowing.median_skill,
331            "an unknown accumulator should flatter the model: {} vs {}",
332            not_knowing.median_skill,
333            knowing.median_skill
334        );
335    }
336
337    #[test]
338    fn training_and_scoring_windows_do_not_overlap() {
339        // A cell whose behaviour changes halfway must not be scored using the
340        // half it was fitted on.
341        let mut values: Vec<f64> = (0..60).map(|i| ((i as f64) * 0.3).sin()).collect();
342        values.extend((0..60).map(|_| 0.0));
343        let report = evaluate(&series(values), &[], 0.5);
344        assert_eq!(report.cells, 1);
345        // 120 values give 119 deltas; half are fitted, so at most 60 can be
346        // scored, and the fitting half must not appear among them.
347        assert!(report.best[0].samples > 0);
348        assert!(report.best[0].samples <= 60);
349        assert!(report.best[0].samples < 119);
350    }
351
352    #[test]
353    fn short_series_are_not_scored() {
354        let report = evaluate(&series(vec![1.0, 2.0, 3.0]), &[], 0.7);
355        assert_eq!(report.cells, 0);
356    }
357
358    #[test]
359    fn ar1_recovers_a_known_coefficient() {
360        // y(t+1) = 0.5*y(t) + 2
361        let mut values = vec![1.0];
362        for _ in 0..50 {
363            let last = *values.last().unwrap();
364            values.push(0.5 * last + 2.0);
365        }
366        let (a, b) = fit_ar1(&values).unwrap();
367        assert!((a - 0.5).abs() < 1e-6, "a = {a}");
368        assert!((b - 2.0).abs() < 1e-6, "b = {b}");
369    }
370}