1use std::collections::BTreeMap;
41
42use corescout_represent::discover::{differences, mean};
43
44#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct CellScore {
47 pub row: usize,
48 pub col: usize,
49 pub model_mae: f64,
51 pub baseline_mae: f64,
53 pub skill: f64,
56 pub samples: usize,
57}
58
59#[derive(Debug, Clone, PartialEq)]
61pub struct PredictionReport {
62 pub cells: usize,
64 pub cells_with_skill: usize,
66 pub median_skill: f64,
68 pub mean_skill: f64,
70 pub best: Vec<CellScore>,
72 pub worst: Vec<CellScore>,
74 pub accumulator_columns: Vec<usize>,
76}
77
78pub 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 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
130fn score_cell(
132 row: usize,
133 col: usize,
134 values: &[f64],
135 is_accumulator: bool,
136 train_fraction: f64,
137) -> Option<CellScore> {
138 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 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 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 -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
215fn 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 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 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 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 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 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 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 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 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 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}