1use std::collections::{BTreeMap, HashMap};
77
78use serde::{Deserialize, Serialize};
79
80use super::summary::{Apply, Check as Logged, Gate, Label, Summary};
81use super::{ARMS, Route, Unit};
82use crate::conscience::{self, Challenge};
83use crate::memory::Rng;
84
85pub const ALPHA: f64 = 0.10;
92pub const DELTA: f64 = 0.05;
95pub const DRAWS: usize = conscience::P_DRAWS;
98pub const SOLVE_MAX: usize = 16;
103
104pub const PRIOR_SD: f64 = 2.5;
108pub const ASK_PRIOR: (f64, f64) = (0.0, 0.35);
111pub const NOT_PRIOR: (f64, f64) = (-0.3, 0.25);
116pub const NOT_LEARNED_UNTIL: u64 = 100;
119pub const DILUTION_PRIOR: (f64, f64) = (0.0, 0.1);
123pub const ARD_START: f64 = 1.0;
126pub const LEVEL_START: f64 = 0.5;
128pub const SD_FLOOR: f64 = 0.01;
131pub const HALF_LIVES: [Option<u32>; 5] = [None, Some(64), Some(32), Some(16), Some(8)];
134pub const FIT_SESSIONS: usize = 256;
137pub const MAX_IPW: f64 = 10.0;
140pub const PSI_FLOOR: f64 = 1e-3;
144pub const POOL_STEPS: [i32; 5] = [-2, -1, 0, 1, 2];
147pub const LATENCY_MIN: usize = 20;
150pub const LATENCY_NEAR: f64 = 0.9;
157
158pub const ROUTE_LABELS_MIN: usize = 5;
167
168const TOL: f64 = 1e-8;
170const MAX_NEWTON: usize = 50;
172const MAX_HALVINGS: usize = 30;
175const MAX_EB: usize = 50;
177const EB_TOL: f64 = 1e-4;
179const PREQ_SESSIONS: usize = 16;
182const CS_SESSIONS: f64 = 30.0;
185const CS_MIN: usize = 10;
188const PRICE_STEPS: usize = 60;
190
191const B0: usize = 0;
193const G2: usize = 1;
194const G3: usize = 2;
195const RANK: usize = 3;
196const STOP: usize = 4;
197const THREE: usize = 5;
198const ASK: usize = 6;
199const NOT: usize = 7;
200const ASK_M: usize = 8;
201const FREE_M: usize = 9;
202const ASK_STOP: usize = 10;
203const ASK_G3: usize = 11;
204const NOT_G: usize = 12;
205const POOL: usize = 13;
206const THREE_STOP: usize = 14;
207pub const P: usize = 15;
210pub const NAMES: [&str; P] = [
212 "intercept",
213 "grade2",
214 "grade3",
215 "rank",
216 "stop",
217 "three",
218 "ask",
219 "not",
220 "ask_dilution",
221 "free_dilution",
222 "ask_stop",
223 "ask_grade3",
224 "not_grade",
225 "pool",
226 "three_stop",
227];
228const SESSION: usize = 0;
230const LESSON: usize = 1;
231const JUDGE: usize = 2;
232const FAMILIES: usize = 3;
233
234#[derive(Debug, Clone, Copy)]
236struct Coef {
237 mean: f64,
238 sd: f64,
239 ard: bool,
240 nonpos: bool,
241}
242
243fn coefs() -> [Coef; P] {
246 let fixed = |(mean, sd): (f64, f64), nonpos| Coef { mean, sd, ard: false, nonpos };
247 let ard = |nonpos| Coef { mean: 0.0, sd: ARD_START, ard: true, nonpos };
248 let mut c = [fixed((0.0, PRIOR_SD), false); P];
249 c[RANK] = ard(false);
250 c[ASK] = fixed(ASK_PRIOR, false);
251 c[NOT] = fixed(NOT_PRIOR, false);
252 c[ASK_M] = fixed(DILUTION_PRIOR, true);
253 c[FREE_M] = fixed(DILUTION_PRIOR, true);
254 for i in [ASK_STOP, ASK_G3, NOT_G] {
255 c[i] = ard(false);
256 }
257 c[POOL] = ard(true);
258 c
259}
260
261const PRUNED_KEEPS: [usize; 7] = [B0, G2, G3, ASK, NOT, ASK_M, FREE_M];
264
265fn pruned() -> [Coef; P] {
268 let mut c = coefs();
269 for (k, x) in c.iter_mut().enumerate() {
270 if !PRUNED_KEEPS.contains(&k) {
271 *x = Coef { mean: 0.0, sd: SD_FLOOR, ard: false, nonpos: false };
272 }
273 }
274 c
275}
276
277#[derive(Debug, Clone, Copy, PartialEq)]
279pub struct Feat {
280 pub grade: u8,
281 pub rank: usize,
282 pub stop: bool,
284 pub three: bool,
286 pub pool: f64,
288}
289
290impl Feat {
291 pub fn of(units: &[Unit], stop: bool, tokens: u32) -> Vec<Feat> {
293 let three = units.iter().any(|u| u.grade == 3);
294 let pool = pool_x(tokens);
295 units.iter().map(|u| Feat { grade: u.grade, rank: u.rank, stop, three, pool }).collect()
296 }
297}
298
299fn pool_x(tokens: u32) -> f64 {
301 if tokens == 0 { 0.0 } else { (f64::from(tokens) / crate::whole::TOKENS as f64).ln() }
302}
303
304fn design(f: &Feat, route: Route, m: usize) -> [f64; P] {
306 let mut x = [0.0; P];
307 let g = f.grade;
308 x[B0] = 1.0;
309 x[G2] = f64::from(u8::from(g == 2));
310 x[G3] = f64::from(u8::from(g == 3));
311 x[RANK] = (1.0 + f.rank as f64).ln();
312 x[STOP] = f64::from(u8::from(f.stop));
313 x[THREE] = f64::from(u8::from(f.three));
314 x[THREE_STOP] = f64::from(u8::from(f.stop && f.three));
315 x[POOL] = f.pool;
316 let ln_m = (m.max(1) as f64).ln();
317 match route {
318 Route::Ask => {
319 x[ASK] = 1.0;
320 x[ASK_M] = ln_m;
321 x[ASK_STOP] = x[STOP];
322 x[ASK_G3] = x[G3];
323 }
324 Route::Free => x[FREE_M] = ln_m,
325 Route::Placebo | Route::Not => {
326 x[NOT] = 1.0;
327 x[NOT_G] = f64::from(g) - 2.0;
328 }
329 }
330 x
331}
332
333fn dot<const K: usize>(a: &[f64; K], b: &[f64; K]) -> f64 {
334 a.iter().zip(b).map(|(x, y)| x * y).sum()
335}
336
337fn sigmoid(x: f64) -> f64 {
338 if x >= 0.0 {
339 1.0 / (1.0 + (-x).exp())
340 } else {
341 let e = x.exp();
342 e / (1.0 + e)
343 }
344}
345
346fn log1pexp(x: f64) -> f64 {
348 if x > 0.0 { x + (-x).exp().ln_1p() } else { x.exp().ln_1p() }
349}
350
351fn cholesky(a: &mut [f64], n: usize) -> bool {
354 for j in 0..n {
355 let d = a[j * n + j] - (0..j).map(|k| a[j * n + k] * a[j * n + k]).sum::<f64>();
356 if d.is_nan() || d <= 0.0 || !d.is_finite() {
357 return false;
358 }
359 let d = d.sqrt();
360 a[j * n + j] = d;
361 for i in j + 1..n {
362 let s = a[i * n + j] - (0..j).map(|k| a[i * n + k] * a[j * n + k]).sum::<f64>();
363 a[i * n + j] = s / d;
364 }
365 for i in 0..j {
366 a[i * n + j] = 0.0;
367 }
368 }
369 true
370}
371
372fn chol_solve(l: &[f64], n: usize, b: &mut [f64]) {
374 for i in 0..n {
375 let s = b[i] - (0..i).map(|k| l[i * n + k] * b[k]).sum::<f64>();
376 b[i] = s / l[i * n + i];
377 }
378 for i in (0..n).rev() {
379 let s = b[i] - (i + 1..n).map(|k| l[k * n + i] * b[k]).sum::<f64>();
380 b[i] = s / l[i * n + i];
381 }
382}
383
384fn chol_inverse(l: &[f64], n: usize) -> Vec<f64> {
386 let mut inv = vec![0.0; n * n];
387 let mut e = vec![0.0; n];
388 for j in 0..n {
389 e.iter_mut().for_each(|x| *x = 0.0);
390 e[j] = 1.0;
391 chol_solve(l, n, &mut e);
392 for (i, x) in e.iter().enumerate() {
393 inv[i * n + j] = *x;
394 }
395 }
396 inv
397}
398
399fn normal(rng: &mut Rng) -> f64 {
401 let (u, v) = (rng.unit(), rng.unit());
402 (-2.0 * u.ln()).sqrt() * (std::f64::consts::TAU * v).cos()
403}
404
405fn gamma(shape: f64, rng: &mut Rng) -> f64 {
407 if shape < 1.0 {
408 return gamma(shape + 1.0, rng) * rng.unit().powf(1.0 / shape);
409 }
410 let d = shape - 1.0 / 3.0;
411 let c = 1.0 / (9.0 * d).sqrt();
412 for _ in 0..64 {
415 let z = normal(rng);
416 let v = (1.0 + c * z).powi(3);
417 if v <= 0.0 {
418 continue;
419 }
420 if rng.unit().ln() < 0.5 * z * z + d - d * v + d * v.ln() {
421 return d * v;
422 }
423 }
424 d
425}
426
427fn beta_draw(a: f64, b: f64, rng: &mut Rng) -> f64 {
429 let (x, y) = (gamma(a, rng), gamma(b, rng));
430 if x + y > 0.0 { x / (x + y) } else { a / (a + b) }
431}
432
433fn hash(parts: &[&[u8]]) -> u64 {
435 let mut h = blake3::Hasher::new();
436 h.update(b"borg-select-policy-v1\0");
437 for p in parts {
438 h.update(p);
439 h.update(b"\0");
440 }
441 let mut first = [0u8; 8];
442 first.copy_from_slice(&h.finalize().as_bytes()[..8]);
443 u64::from_le_bytes(first)
444}
445
446fn mix(seed: u64, i: u64) -> u64 {
448 Rng::new(seed ^ i.wrapping_add(1).wrapping_mul(0x9e37_79b9_7f4a_7c15)).next_u64()
449}
450
451#[derive(Debug, Clone)]
453struct Obs<const K: usize, const F: usize> {
454 x: [f64; K],
455 y: f64,
456 w: f64,
457 lv: [Option<usize>; F],
458}
459
460#[derive(Debug, Clone)]
462struct Est<const K: usize, const F: usize> {
463 beta: [f64; K],
464 u: [Vec<f64>; F],
465 sd: [f64; K],
467 spread: [f64; F],
468 cov: [[f64; K]; K],
470 var_u: [Vec<f64>; F],
471 converged: bool,
472}
473
474impl<const K: usize, const F: usize> Est<K, F> {
475 fn eta(&self, o: &Obs<K, F>) -> f64 {
476 dot(&self.beta, &o.x) + (0..F).filter_map(|f| o.lv[f].map(|l| self.u[f][l])).sum::<f64>()
477 }
478}
479
480fn map<const K: usize, const F: usize>(
485 obs: &[Obs<K, F>],
486 levels: [usize; F],
487 spec: &[Coef; K],
488 sd: &[f64; K],
489 spread: &[f64; F],
490 warm: Option<&Est<K, F>>,
491) -> Option<Est<K, F>> {
492 let elim = (0..F).filter(|&f| levels[f] > 0).max_by_key(|&f| (levels[f], std::cmp::Reverse(f)));
493 let mut off = [0usize; F];
494 let mut dim = K;
495 for f in 0..F {
496 if Some(f) != elim {
497 off[f] = dim;
498 dim += levels[f];
499 }
500 }
501 let members: Vec<Vec<usize>> = match elim {
502 Some(e) => {
503 let mut m = vec![Vec::new(); levels[e]];
504 for (i, o) in obs.iter().enumerate() {
505 if let Some(l) = o.lv[e] {
506 m[l].push(i);
507 }
508 }
509 m
510 }
511 None => Vec::new(),
512 };
513 let mut beta: [f64; K] = std::array::from_fn(|k| warm.map_or(spec[k].mean, |w| w.beta[k]));
514 for k in 0..K {
515 if spec[k].nonpos {
516 beta[k] = beta[k].min(0.0);
517 }
518 }
519 let mut u: [Vec<f64>; F] = std::array::from_fn(|f| match warm {
520 Some(w) if w.u[f].len() == levels[f] => w.u[f].clone(),
521 _ => vec![0.0; levels[f]],
522 });
523 let prec: [f64; K] = std::array::from_fn(|k| 1.0 / (sd[k] * sd[k]));
524 let uprec: [f64; F] = std::array::from_fn(|f| 1.0 / (spread[f] * spread[f]));
525 let objective = |beta: &[f64; K], u: &[Vec<f64>; F]| -> f64 {
526 let mut ll = 0.0;
527 for o in obs {
528 let eta = dot(beta, &o.x) + (0..F).filter_map(|f| o.lv[f].map(|l| u[f][l])).sum::<f64>();
529 ll += o.w * (o.y * eta - log1pexp(eta));
530 }
531 let pb: f64 = (0..K).map(|k| (beta[k] - spec[k].mean).powi(2) * prec[k]).sum();
532 let pu: f64 = (0..F).map(|f| u[f].iter().map(|x| x * x).sum::<f64>() * uprec[f]).sum();
533 ll - 0.5 * (pb + pu)
534 };
535 let mut converged = false;
536 let mut s = vec![0.0; dim * dim];
537 let mut d_e: Vec<f64> = Vec::new();
538 let mut b_e: Vec<Vec<(usize, f64)>> = Vec::new();
539 let mut current = objective(&beta, &u);
540 for _ in 0..MAX_NEWTON {
541 let mut g = vec![0.0; dim];
543 s.iter_mut().for_each(|x| *x = 0.0);
544 let mut r = vec![0.0; obs.len()];
545 let mut wt = vec![0.0; obs.len()];
546 let mut dense: Vec<(usize, f64)> = Vec::with_capacity(K + F);
547 for (i, o) in obs.iter().enumerate() {
548 let eta = dot(&beta, &o.x) + (0..F).filter_map(|f| o.lv[f].map(|l| u[f][l])).sum::<f64>();
549 let mu = sigmoid(eta);
550 r[i] = o.w * (o.y - mu);
551 wt[i] = o.w * mu * (1.0 - mu);
552 dense.clear();
553 dense.extend((0..K).filter(|&k| o.x[k] != 0.0).map(|k| (k, o.x[k])));
554 dense.extend((0..F).filter(|&f| Some(f) != elim).filter_map(|f| o.lv[f].map(|l| (off[f] + l, 1.0))));
555 for &(a, xa) in &dense {
556 g[a] += r[i] * xa;
557 for &(b, xb) in &dense {
558 s[a * dim + b] += wt[i] * xa * xb;
559 }
560 }
561 }
562 for k in 0..K {
563 g[k] -= (beta[k] - spec[k].mean) * prec[k];
564 s[k * dim + k] += prec[k];
565 }
566 for f in (0..F).filter(|&f| Some(f) != elim) {
567 for l in 0..levels[f] {
568 g[off[f] + l] -= u[f][l] * uprec[f];
569 s[(off[f] + l) * dim + off[f] + l] += uprec[f];
570 }
571 }
572 let mut g_e: Vec<f64> = Vec::new();
574 d_e.clear();
575 b_e.clear();
576 if let Some(e) = elim {
577 for (l, rows) in members.iter().enumerate() {
578 let mut d = uprec[e];
579 let mut gl = -u[e][l] * uprec[e];
580 let mut b: Vec<(usize, f64)> = Vec::new();
581 for &i in rows {
582 d += wt[i];
583 gl += r[i];
584 let o = &obs[i];
585 for k in 0..K {
586 add_sparse(&mut b, k, wt[i] * o.x[k]);
587 }
588 for f in (0..F).filter(|&f| f != e) {
589 if let Some(lv) = o.lv[f] {
590 add_sparse(&mut b, off[f] + lv, wt[i]);
591 }
592 }
593 }
594 for &(a, va) in &b {
595 for &(c, vc) in &b {
596 s[a * dim + c] -= va * vc / d;
597 }
598 g[a] -= va * gl / d;
599 }
600 d_e.push(d);
601 g_e.push(gl);
602 b_e.push(b);
603 }
604 }
605 let mut held: Vec<usize> = (0..K).filter(|&k| spec[k].nonpos && beta[k] >= 0.0 && g[k] > 0.0).collect();
608 let step = loop {
609 let mut delta = vec![0.0; dim];
610 for &k in &held {
611 delta[k] = -beta[k];
612 }
613 let free: Vec<usize> = (0..dim).filter(|i| !held.contains(i)).collect();
614 let n = free.len();
615 let mut a = vec![0.0; n * n];
616 let mut rhs = vec![0.0; n];
617 for (fi, &i) in free.iter().enumerate() {
618 rhs[fi] = g[i] - held.iter().map(|&h| s[i * dim + h] * delta[h]).sum::<f64>();
619 for (fj, &j) in free.iter().enumerate() {
620 a[fi * n + fj] = s[i * dim + j];
621 }
622 }
623 if !cholesky(&mut a, n) {
624 return None;
625 }
626 chol_solve(&a, n, &mut rhs);
627 for (fi, &i) in free.iter().enumerate() {
628 delta[i] = rhs[fi];
629 }
630 let out: Vec<usize> = (0..K).filter(|&k| spec[k].nonpos && !held.contains(&k) && beta[k] + delta[k] > 0.0).collect();
631 if out.is_empty() {
632 break delta;
633 }
634 held.extend(out);
635 };
636 let delta_e: Vec<f64> = (0..d_e.len()).map(|l| (g_e[l] - b_e[l].iter().map(|&(a, v)| v * step[a]).sum::<f64>()) / d_e[l]).collect();
637 let largest = step.iter().chain(&delta_e).fold(0.0f64, |m, x| m.max(x.abs()));
638 let mut t = 1.0;
640 let mut accepted = false;
641 for _ in 0..=MAX_HALVINGS {
642 let nb: [f64; K] = std::array::from_fn(|k| {
643 let x = beta[k] + t * step[k];
644 if spec[k].nonpos { x.min(0.0) } else { x }
645 });
646 let mut nu = u.clone();
647 for f in 0..F {
648 for l in 0..levels[f] {
649 nu[f][l] += t * if Some(f) == elim { delta_e[l] } else { step[off[f] + l] };
650 }
651 }
652 let next = objective(&nb, &nu);
653 if next >= current - 1e-12 * current.abs().max(1.0) {
655 (beta, u, current) = (nb, nu, next);
656 accepted = true;
657 break;
658 }
659 t *= 0.5;
660 }
661 if !accepted || largest * t < TOL {
662 converged = accepted || largest < TOL;
663 break;
664 }
665 }
666 if !cholesky(&mut s, dim) {
668 return None;
669 }
670 let inv = chol_inverse(&s, dim);
671 let cov: [[f64; K]; K] = std::array::from_fn(|a| std::array::from_fn(|b| inv[a * dim + b]));
672 let var_u: [Vec<f64>; F] = std::array::from_fn(|f| {
673 if Some(f) == elim {
674 (0..levels[f])
675 .map(|l| {
676 let b = &b_e[l];
677 let q: f64 = b.iter().map(|&(a, va)| b.iter().map(|&(c, vc)| va * inv[a * dim + c] * vc).sum::<f64>()).sum();
678 1.0 / d_e[l] + q / (d_e[l] * d_e[l])
679 })
680 .collect()
681 } else {
682 (0..levels[f]).map(|l| inv[(off[f] + l) * dim + off[f] + l]).collect()
683 }
684 });
685 Some(Est { beta, u, sd: *sd, spread: *spread, cov, var_u, converged })
686}
687
688fn add_sparse(b: &mut Vec<(usize, f64)>, i: usize, v: f64) {
690 if v == 0.0 {
691 return;
692 }
693 match b.iter_mut().find(|(j, _)| *j == i) {
694 Some(e) => e.1 += v,
695 None => b.push((i, v)),
696 }
697}
698
699fn eb<const K: usize, const F: usize>(obs: &[Obs<K, F>], levels: [usize; F], spec: &[Coef; K], warm: Option<&Est<K, F>>) -> Option<Est<K, F>> {
703 let mut sd: [f64; K] = std::array::from_fn(|k| warm.filter(|_| spec[k].ard).map_or(spec[k].sd, |w| w.sd[k]));
704 let mut spread: [f64; F] = std::array::from_fn(|f| warm.map_or(LEVEL_START, |w| w.spread[f]));
705 let mut est = map(obs, levels, spec, &sd, &spread, warm)?;
706 for _ in 0..MAX_EB {
707 let nsd: [f64; K] = std::array::from_fn(|k| match spec[k].ard {
708 true => ((est.beta[k] - spec[k].mean).powi(2) + est.cov[k][k]).sqrt().clamp(SD_FLOOR, PRIOR_SD),
709 false => sd[k],
710 });
711 let nspread: [f64; F] = std::array::from_fn(|f| match levels[f] {
712 0 => spread[f],
713 n => (est.u[f].iter().zip(&est.var_u[f]).map(|(x, v)| x * x + v).sum::<f64>() / n as f64).sqrt().clamp(SD_FLOOR, PRIOR_SD),
714 });
715 let moved = sd.iter().zip(&nsd).chain(spread.iter().zip(&nspread)).any(|(a, b)| (a - b).abs() > EB_TOL * a.abs().max(SD_FLOOR));
716 (sd, spread) = (nsd, nspread);
717 est = map(obs, levels, spec, &sd, &spread, Some(&est))?;
718 if !moved {
719 break;
720 }
721 }
722 Some(est)
723}
724
725#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
728pub struct Rates {
729 pub free_stop: f64,
731 pub free_live: f64,
733 pub later: f64,
736 pub wait: f64,
738 pub ask_live: f64,
744}
745
746#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
748pub struct PoolFit {
749 #[serde(default, skip_serializing_if = "Vec::is_empty")]
752 pub yields: Vec<(f64, f64)>,
753 #[serde(default, skip_serializing_if = "is_default")]
755 pub per_lesson: f64,
756 #[serde(default, skip_serializing_if = "is_default")]
759 pub latency: (f64, f64, f64),
760 #[serde(default, skip_serializing_if = "is_default")]
761 pub latency_n: u32,
762 #[serde(default, skip_serializing_if = "is_default")]
766 pub latency_reach: u32,
767 #[serde(default, skip_serializing_if = "is_default")]
770 pub eta: f64,
771 #[serde(default, skip_serializing_if = "is_default")]
772 pub slope: (f64, f64),
773 #[serde(default, skip_serializing_if = "is_default")]
774 pub not: (f64, f64),
775}
776
777#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
779pub struct Model {
780 pub beta: Vec<f64>,
783 pub chol: Vec<f64>,
784 pub sd: Vec<f64>,
786 pub spread: [f64; FAMILIES],
788 pub sessions: BTreeMap<String, (f64, f64)>,
790 pub lessons: BTreeMap<String, (f64, f64)>,
791 pub judges: BTreeMap<String, (f64, f64)>,
792 #[serde(default, skip_serializing_if = "String::is_empty")]
794 pub judge: String,
795 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
796 pub converged: bool,
797}
798
799impl Model {
800 #[must_use]
804 pub fn current(&self) -> bool {
805 self.beta.len() == P && self.chol.len() == P * P && self.sd.len() == P
806 }
807}
808
809#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
812pub struct Fit {
813 #[serde(default, skip_serializing_if = "String::is_empty")]
816 pub key: String,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub model: Option<Model>,
820 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub half_life: Option<u32>,
823 #[serde(default, skip_serializing_if = "is_default")]
824 pub rates: Rates,
825 #[serde(default, skip_serializing_if = "is_default")]
826 pub pool: PoolFit,
827 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub budget: Option<f64>,
831 #[serde(default, skip_serializing_if = "is_default")]
832 pub lambda: f64,
833 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
836 pub sensitive: bool,
837 #[serde(default, skip_serializing_if = "is_default")]
839 pub labels: u32,
840 #[serde(default, skip_serializing_if = "is_default")]
841 pub dropped: u32,
842}
843
844fn is_default<T: Default + PartialEq>(v: &T) -> bool {
845 *v == T::default()
846}
847
848pub(super) fn decay(age: u32, h: Option<u32>) -> f64 {
850 h.map_or(1.0, |h| 0.5f64.powf(f64::from(age) / f64::from(h)))
851}
852
853struct Data {
856 obs: Vec<Obs<P, FAMILIES>>,
857 tick: Vec<u32>,
860 base_w: Vec<f64>,
861 unclear: Vec<bool>,
862 levels: [usize; FAMILIES],
863 names: [Vec<String>; FAMILIES],
864 lv_of: HashMap<usize, [Option<usize>; FAMILIES]>,
866 judged_p: HashMap<usize, f64>,
867 dropped: u32,
868}
869
870fn level_of(names: &mut [Vec<String>; FAMILIES], index: &mut [HashMap<String, usize>; FAMILIES], tagged: &(usize, String)) -> usize {
872 let (f, name) = tagged;
873 *index[*f].entry(name.clone()).or_insert_with(|| {
874 names[*f].push(name.clone());
875 names[*f].len() - 1
876 })
877}
878
879fn model_route(a: &Apply) -> Option<Route> {
882 match a.route {
883 Route::Ask | Route::Free if !a.delivered.never() => Some(a.route),
884 Route::Placebo => Some(Route::Not),
885 _ => None,
886 }
887}
888
889fn applies_of<'a>(s: &'a Summary, c: &Logged) -> &'a [Apply] {
891 s.applies.get(c.first as usize..c.first as usize + usize::from(c.n)).unwrap_or(&[])
892}
893
894fn logged_had(s: &Summary, c: &Logged) -> Vec<String> {
897 applies_of(s, c).iter().filter(|a| a.nofree).filter_map(|a| s.lessons.get(a.lesson as usize).cloned()).collect()
898}
899
900fn check_view<'a>(s: &'a Summary, c: &Logged) -> (Vec<Unit<'a>>, Vec<Feat>, Vec<Route>) {
902 let applies = applies_of(s, c);
903 let units: Vec<Unit> =
904 applies.iter().map(|a| Unit { lesson: s.lessons.get(a.lesson as usize).map_or("", String::as_str), grade: a.grade, rank: a.rank as usize }).collect();
905 let feats = Feat::of(&units, c.stop, c.tokens);
906 let routes = applies.iter().map(|a| if a.route == Route::Placebo { Route::Not } else { a.route }).collect();
907 (units, feats, routes)
908}
909
910impl Data {
911 fn of(s: &Summary, zero: bool) -> Data {
914 let now = s.sessions.len() as u32;
915 let from = now.saturating_sub(FIT_SESSIONS as u32);
916 let mut names: [Vec<String>; FAMILIES] = Default::default();
917 let mut index: [HashMap<String, usize>; FAMILIES] = Default::default();
918 let judged_p = judged(s);
919 let mut d = Data {
920 obs: Vec::new(),
921 tick: Vec::new(),
922 base_w: Vec::new(),
923 unclear: Vec::new(),
924 levels: [0; FAMILIES],
925 names: Default::default(),
926 lv_of: HashMap::new(),
927 judged_p: HashMap::new(),
928 dropped: 0,
929 };
930 let names_of = |c: &Logged, a: &Apply| -> [Option<(usize, String)>; FAMILIES] {
931 [
932 Some((SESSION, s.sessions.get(c.tick as usize).map_or("", |x| x.id.as_str()).to_string())),
933 s.lessons.get(a.lesson as usize).map(|n| (LESSON, n.clone())),
934 a.judge.and_then(|j| s.judges.get(j as usize).cloned()).map(|n| (JUDGE, n)),
935 ]
936 };
937 for c in &s.checks {
938 let (_, feats, _) = check_view(s, c);
939 let (m_q, m_f) = (usize::from(c.m_q), usize::from(c.m_f));
940 for (j, f) in feats.iter().enumerate() {
941 let i = c.first as usize + j;
942 let a = &s.applies[i];
943 let Some(route) = model_route(a) else { continue };
944 let y = match a.label {
945 Label::Changed => 1.0,
946 Label::Ignored | Label::NotArisen => 0.0,
948 Label::Unclear | Label::Remains if zero => 0.0,
950 Label::Unclear | Label::Remains | Label::Open | Label::Censored => continue,
951 };
952 if c.tick < from {
953 d.dropped += 1;
954 continue;
955 }
956 let m = match route {
957 Route::Ask => m_q,
958 Route::Free => m_f,
959 _ => 1,
960 };
961 let p = judged_p.get(&i).copied().unwrap_or(1.0);
962 let w = 1.0 / p.max(1.0 / MAX_IPW);
963 let lv = names_of(c, a).map(|n| n.map(|n| level_of(&mut names, &mut index, &n)));
964 d.obs.push(Obs { x: design(f, route, m), y, w, lv });
965 d.tick.push(c.tick);
966 d.base_w.push(w);
967 d.unclear.push(a.label.pending());
968 }
969 }
970 for c in &s.checks {
972 for i in c.first as usize..(c.first as usize + c.n as usize).min(s.applies.len()) {
973 let n = names_of(c, &s.applies[i]);
974 d.lv_of.insert(i, std::array::from_fn(|f| n[f].as_ref().and_then(|(_, n)| index[f].get(n).copied())));
975 }
976 }
977 d.levels = std::array::from_fn(|f| names[f].len());
978 d.names = names;
979 d.judged_p = judged_p;
980 d
981 }
982
983 fn without_levels(mut self) -> Data {
985 for o in &mut self.obs {
986 o.lv = [None; FAMILIES];
987 }
988 for lv in self.lv_of.values_mut() {
989 *lv = [None; FAMILIES];
990 }
991 self.levels = [0; FAMILIES];
992 self.names = Default::default();
993 self
994 }
995
996 fn weighted(&self, now: u32, h: Option<u32>) -> Vec<Obs<P, FAMILIES>> {
998 self.obs.iter().zip(&self.tick).zip(&self.base_w).map(|((o, t), w)| Obs { w: w * decay(now.saturating_sub(*t), h), ..o.clone() }).collect()
999 }
1000}
1001
1002pub(crate) fn judged(s: &Summary) -> HashMap<usize, f64> {
1007 const K: usize = 6;
1009 let mut last: HashMap<u32, usize> = HashMap::new();
1010 for (i, c) in s.checks.iter().enumerate() {
1011 last.insert(c.tick, i);
1012 }
1013 let mut rows: Vec<(usize, Obs<K, 0>)> = Vec::new();
1014 for (ci, c) in s.checks.iter().enumerate() {
1015 for i in c.first as usize..(c.first as usize + c.n as usize).min(s.applies.len()) {
1016 let a = &s.applies[i];
1017 if model_route(a).is_none() || a.label == Label::Open {
1018 continue;
1019 }
1020 let y = f64::from(u8::from(a.label.verdict()));
1021 let x = [
1022 1.0,
1023 f64::from(u8::from(a.route == Route::Ask)),
1024 f64::from(u8::from(a.route == Route::Free)),
1025 f64::from(u8::from(c.stop)),
1026 (1.0 + f64::from(a.pending)).ln(),
1027 f64::from(u8::from(last.get(&c.tick) == Some(&ci))),
1028 ];
1029 rows.push((i, Obs { x, y, w: 1.0, lv: [] }));
1030 }
1031 }
1032 if rows.is_empty() {
1033 return HashMap::new();
1034 }
1035 let spec = [Coef { mean: 0.0, sd: PRIOR_SD, ard: false, nonpos: false }; K];
1036 let obs: Vec<Obs<K, 0>> = rows.iter().map(|(_, o)| o.clone()).collect();
1037 let Some(est) = map(&obs, [], &spec, &[PRIOR_SD; K], &[], None) else { return HashMap::new() };
1038 rows.iter().map(|(i, o)| (*i, sigmoid(est.eta(o)))).collect()
1039}
1040
1041fn loglik(est: &Est<P, FAMILIES>, obs: &[Obs<P, FAMILIES>]) -> f64 {
1043 obs.iter()
1044 .map(|o| {
1045 let p = sigmoid(est.eta(o)).clamp(1e-12, 1.0 - 1e-12);
1047 o.w * (o.y * p.ln() + (1.0 - o.y) * (1.0 - p).ln())
1048 })
1049 .sum()
1050}
1051
1052fn half_life(d: &Data, base: &Est<P, FAMILIES>, spec: &[Coef; P]) -> Option<u32> {
1056 let mut ticks: Vec<u32> = d.tick.clone();
1057 ticks.sort_unstable();
1058 ticks.dedup();
1059 let eval: Vec<u32> = ticks.iter().skip(1).rev().take(PREQ_SESSIONS).rev().copied().collect();
1060 if eval.is_empty() {
1061 return None;
1062 }
1063 let mut best: (f64, Option<u32>) = (f64::NEG_INFINITY, None);
1064 for h in HALF_LIVES {
1065 let mut score = 0.0;
1066 let mut warm: Option<Est<P, FAMILIES>> = None;
1067 for &t in &eval {
1068 let train: Vec<Obs<P, FAMILIES>> = d
1069 .obs
1070 .iter()
1071 .zip(&d.tick)
1072 .zip(&d.base_w)
1073 .filter(|((_, tk), _)| **tk < t)
1074 .map(|((o, tk), w)| Obs { w: w * decay(t - tk, h), ..o.clone() })
1075 .collect();
1076 let test: Vec<Obs<P, FAMILIES>> = d.obs.iter().zip(&d.tick).filter(|(_, tk)| **tk == t).map(|(o, _)| o.clone()).collect();
1077 let Some(est) = map(&train, d.levels, spec, &base.sd, &base.spread, warm.as_ref()) else { continue };
1078 score += loglik(&est, &test);
1079 warm = Some(est);
1080 }
1081 if score > best.0 + 1e-9 * best.0.abs().max(1.0) || best.0 == f64::NEG_INFINITY {
1083 best = (score, h);
1084 }
1085 }
1086 best.1
1087}
1088
1089fn model_of(est: &Est<P, FAMILIES>, d: &Data, judge: String) -> Option<Model> {
1092 let mut chol: Vec<f64> = est.cov.iter().flatten().copied().collect();
1093 let finite = est.beta.iter().chain(&est.sd).chain(&est.spread).chain(est.u.iter().flatten()).chain(est.var_u.iter().flatten()).all(|x| x.is_finite());
1094 if !finite || !cholesky(&mut chol, P) {
1095 return None;
1096 }
1097 let levels = |f: usize| -> BTreeMap<String, (f64, f64)> {
1098 d.names[f].iter().enumerate().map(|(l, n)| (n.clone(), (est.u[f][l], est.var_u[f][l].max(0.0).sqrt()))).collect()
1099 };
1100 Some(Model {
1101 beta: est.beta.to_vec(),
1102 chol,
1103 sd: est.sd.to_vec(),
1104 spread: est.spread,
1105 sessions: levels(SESSION),
1106 lessons: levels(LESSON),
1107 judges: levels(JUDGE),
1108 judge,
1109 converged: est.converged,
1110 })
1111}
1112
1113#[must_use]
1116pub fn fit(s: &Summary, budget: Option<f64>) -> Fit {
1117 fit_with(s, budget, &coefs(), true)
1118}
1119
1120#[must_use]
1126pub fn fit_pruned(s: &Summary, budget: Option<f64>) -> Fit {
1127 fit_with(s, budget, &pruned(), false)
1128}
1129
1130fn fit_with(s: &Summary, budget: Option<f64>, spec: &[Coef; P], levels: bool) -> Fit {
1132 let data = |zero: bool| if levels { Data::of(s, zero) } else { Data::of(s, zero).without_levels() };
1133 let d = data(false);
1134 let now = s.sessions.len() as u32;
1135 let mut fit = Fit { key: s.key(), labels: d.obs.len() as u32, dropped: d.dropped, budget: budget.filter(|b| *b > 0.0), ..Fit::default() };
1136 let base = if d.obs.is_empty() { None } else { eb(&d.obs, d.levels, spec, None) };
1137 if let Some(base) = &base {
1138 fit.half_life = half_life(&d, base, spec);
1139 let est = eb(&d.weighted(now, fit.half_life), d.levels, spec, Some(base));
1140 let judge = s.checks.iter().rev().flat_map(|c| applies_of(s, c)).find_map(|a| a.judge);
1141 let judge = judge.filter(|_| levels).and_then(|j| s.judges.get(j as usize)).cloned().unwrap_or_default();
1142 if let Some(est) = est {
1143 let z = data(true);
1144 if z.unclear.iter().any(|u| *u)
1145 && let Some(alt) = eb(&z.weighted(now, fit.half_life), z.levels, spec, None)
1146 {
1147 fit.sensitive = [ASK, NOT].iter().any(|&k| est.beta[k].signum() != alt.beta[k].signum());
1148 }
1149 let eta = d.obs.iter().map(|o| est.eta(o)).sum::<f64>() / d.obs.len() as f64;
1150 fit.pool.eta = eta;
1151 fit.pool.slope = (est.beta[POOL], est.cov[POOL][POOL].max(0.0).sqrt());
1152 fit.pool.not = (est.beta[NOT], est.cov[NOT][NOT].max(0.0).sqrt());
1153 fit.model = model_of(&est, &d, judge).map(|m| if levels { m } else { Model { spread: [0.0; FAMILIES], ..m } });
1155 }
1156 }
1157 fit.rates = rates(s, fit.half_life);
1158 pool_state(s, &mut fit.pool);
1159 if let Some(b) = fit.budget {
1160 fit.lambda = price(s, &fit, b);
1161 }
1162 fit
1163}
1164
1165#[must_use]
1170pub fn prior() -> Fit {
1171 prior_with(&coefs(), true)
1172}
1173
1174#[must_use]
1178pub fn prior_pruned() -> Fit {
1179 prior_with(&pruned(), false)
1180}
1181
1182fn prior_with(spec: &[Coef; P], levels: bool) -> Fit {
1185 let s = Summary::default();
1186 let d = Data::of(&s, false);
1187 let sd: [f64; P] = std::array::from_fn(|k| spec[k].sd);
1188 let model = map(&d.obs, d.levels, spec, &sd, &[LEVEL_START; FAMILIES], None)
1189 .and_then(|est| model_of(&est, &d, String::new()))
1190 .map(|m| if levels { m } else { Model { spread: [0.0; FAMILIES], ..m } });
1191 Fit { model, rates: rates(&s, None), ..Fit::default() }
1192}
1193
1194const PROBIT: f64 = std::f64::consts::PI / 8.0;
1197
1198#[must_use]
1204pub fn predict(fit: &Fit, f: &Feat, route: Route, m: usize, session: &str, lesson: &str) -> Option<f64> {
1205 let model = fit.model.as_ref().filter(|m| m.current())?;
1206 let x = design(f, route, m);
1207 let lx = (0..P).map(|k| (k..P).map(|i| model.chol[i * P + k] * x[i]).sum::<f64>());
1209 let mut var: f64 = lx.map(|v| v * v).sum();
1210 let mut mean: f64 = model.beta.iter().zip(&x).map(|(b, x)| b * x).sum();
1211 for (map, name, spread) in [
1212 (&model.sessions, session, model.spread[SESSION]),
1213 (&model.lessons, lesson, model.spread[LESSON]),
1214 (&model.judges, model.judge.as_str(), model.spread[JUDGE]),
1215 ] {
1216 let (mu, sd) = map.get(name).copied().unwrap_or((0.0, spread));
1217 mean += mu;
1218 var += sd * sd;
1219 }
1220 Some(sigmoid(mean / (1.0 + PROBIT * var).sqrt()))
1221}
1222
1223fn rates(s: &Summary, h: Option<u32>) -> Rates {
1226 let mean = |(k, miss): (f64, f64)| (1.0 + k) / (2.0 + k + miss);
1227 let [free_stop, free_live, later, wait, ask] = s.tallies(h);
1228 let ask_live = if ask.0 + ask.1 > 0.0 { mean(ask) } else { 1.0 };
1230 Rates { free_stop: mean(free_stop), free_live: mean(free_live), later: mean(later), wait: mean(wait), ask_live }
1231}
1232
1233fn pool_state(s: &Summary, pool: &mut PoolFit) {
1236 let bins: Vec<(f64, f64)> = s.pool.bins.iter().take_while(|(n, _)| *n > 0).map(|&(n, k)| (1.0 + k as f64, 1.0 + n.saturating_sub(k) as f64)).collect();
1240 pool.yields = pava(&bins);
1241 pool.per_lesson = if s.pool.shown > 0 { s.pool.tokens as f64 / s.pool.shown as f64 } else { 0.0 };
1242 let obs = &s.pool.latency;
1243 pool.latency_n = obs.len() as u32;
1244 let mut read: Vec<u32> = obs.iter().map(|o| o.0).collect();
1245 read.sort_unstable_by(|a, b| b.cmp(a));
1246 pool.latency_reach = read.get(LATENCY_MIN - 1).copied().unwrap_or(0);
1247 if obs.len() >= 2 {
1248 let n = obs.len() as f64;
1249 let mx = obs.iter().map(|o| f64::from(o.0)).sum::<f64>() / n;
1250 let my = obs.iter().map(|o| f64::from(o.1)).sum::<f64>() / n;
1251 let sxx: f64 = obs.iter().map(|o| (f64::from(o.0) - mx).powi(2)).sum();
1252 let sxy: f64 = obs.iter().map(|o| (f64::from(o.0) - mx) * (f64::from(o.1) - my)).sum();
1253 let b = if sxx > 0.0 { (sxy / sxx).max(0.0) } else { 0.0 };
1254 let a = my - b * mx;
1255 let mut res: Vec<f64> = obs.iter().map(|o| f64::from(o.1) - a - b * f64::from(o.0)).collect();
1256 res.sort_by(f64::total_cmp);
1257 let q = res[((1.0 - DELTA) * (res.len() - 1) as f64).ceil() as usize];
1258 pool.latency = (a, b, q.max(0.0));
1259 }
1260}
1261
1262fn pava(bins: &[(f64, f64)]) -> Vec<(f64, f64)> {
1265 let mut blocks: Vec<(f64, f64, usize)> = Vec::new();
1266 for &(a, b) in bins {
1267 blocks.push((a, b, 1));
1268 while blocks.len() >= 2 {
1269 let (x, y) = (blocks[blocks.len() - 2], blocks[blocks.len() - 1]);
1270 if x.0 / (x.0 + x.1) >= y.0 / (y.0 + y.1) {
1271 break;
1272 }
1273 blocks.pop();
1274 if let Some(last) = blocks.last_mut() {
1275 *last = (x.0 + y.0, x.1 + y.1, x.2 + y.2);
1276 }
1277 }
1278 }
1279 blocks.iter().flat_map(|&(a, b, n)| std::iter::repeat_n((a, b), n)).collect()
1280}
1281
1282#[derive(Debug, Clone)]
1285struct Theta {
1286 b: [f64; P],
1287 base: Vec<f64>,
1288}
1289
1290struct Levels<'a> {
1293 name: &'a str,
1294 session: u64,
1295 judge: u64,
1296 lessons: Vec<u64>,
1297}
1298
1299impl<'a> Levels<'a> {
1300 fn of(session: &'a str, judge: &str, units: &[Unit]) -> Levels<'a> {
1301 Levels {
1302 name: session,
1303 session: hash(&[b"session", session.as_bytes()]),
1304 judge: hash(&[b"judge", judge.as_bytes()]),
1305 lessons: units.iter().map(|u| hash(&[b"lesson", u.lesson.as_bytes()])).collect(),
1306 }
1307 }
1308}
1309
1310fn draw_beta(m: &Model, seed: u64) -> [f64; P] {
1317 let mut rng = Rng::new(seed);
1318 let mut z: [f64; P] = std::array::from_fn(|_| normal(&mut rng));
1319 let spec = coefs();
1320 let l = |i: usize, k: usize| m.chol.get(i * P + k).copied().unwrap_or(0.0);
1321 let mut x = [0.0; P];
1322 for i in 0..P {
1323 let given = m.beta.get(i).copied().unwrap_or(0.0) + (0..i).map(|k| l(i, k) * z[k]).sum::<f64>();
1324 x[i] = given + l(i, i) * z[i];
1325 if spec[i].nonpos {
1326 x[i] = below_zero(given, l(i, i), z[i]);
1327 if l(i, i) > 0.0 {
1329 z[i] = (x[i] - given) / l(i, i);
1330 }
1331 }
1332 }
1333 x
1334}
1335
1336fn below_zero(mean: f64, sd: f64, z: f64) -> f64 {
1340 if sd <= 0.0 || !sd.is_finite() {
1341 return mean.min(0.0);
1342 }
1343 let bound = -mean / sd;
1344 let ln_lower = ln_phi(z) + ln_phi(bound);
1346 let upper = phi(-z) + phi(z) * phi(-bound);
1347 (mean + sd * probit(ln_lower, upper)).min(0.0)
1348}
1349
1350fn phi(x: f64) -> f64 {
1352 0.5 * erfc(-x / std::f64::consts::SQRT_2)
1353}
1354
1355fn ln_phi(x: f64) -> f64 {
1357 if x < 0.0 { 0.5f64.ln() - 0.5 * x * x + erfcx(-x / std::f64::consts::SQRT_2).ln() } else { (-0.5 * erfc(x / std::f64::consts::SQRT_2)).ln_1p() }
1358}
1359
1360fn erfc(x: f64) -> f64 {
1362 let a = x.abs();
1363 let tail = (-a * a).exp() * erfcx(a);
1364 if x >= 0.0 { tail } else { 2.0 - tail }
1365}
1366
1367fn erfcx(x: f64) -> f64 {
1370 const C: [f64; 10] =
1371 [-1.265_512_23, 1.000_023_68, 0.374_091_96, 0.096_784_18, -0.186_288_06, 0.278_868_07, -1.135_203_98, 1.488_515_87, -0.822_152_23, 0.170_872_77];
1372 let t = 1.0 / (1.0 + 0.5 * x);
1373 t * C.iter().rev().fold(0.0, |acc, c| acc * t + c).exp()
1374}
1375
1376fn probit(ln_lower: f64, upper: f64) -> f64 {
1380 const A: [f64; 6] =
1381 [-3.969_683_028_665_376e1, 2.209_460_984_245_205e2, -2.759_285_104_469_687e2, 1.383_577_518_672_69e2, -3.066_479_806_614_716e1, 2.506_628_277_459_239];
1382 const B: [f64; 5] = [-5.447_609_879_822_406e1, 1.615_858_368_580_409e2, -1.556_989_798_598_866e2, 6.680_131_188_771_972e1, -1.328_068_155_288_572e1];
1383 const C: [f64; 6] =
1384 [-7.784_894_002_430_293e-3, -3.223_964_580_411_365e-1, -2.400_758_277_161_838, -2.549_732_539_343_734, 4.374_664_141_464_968, 2.938_163_982_698_783];
1385 const D: [f64; 4] = [7.784_695_709_041_462e-3, 3.224_671_290_700_398e-1, 2.445_134_137_142_996, 3.754_408_661_907_416];
1386 const LOW: f64 = 0.02425;
1387 let poly = |c: &[f64], x: f64| c.iter().fold(0.0, |acc, k| acc * x + k);
1388 let tail = |ln_p: f64| {
1390 let q = (-2.0 * ln_p).sqrt();
1391 poly(&C, q) / (poly(&D, q) * q + 1.0)
1392 };
1393 let lower = ln_lower.exp();
1394 if lower < LOW {
1395 tail(ln_lower)
1396 } else if upper < LOW {
1397 -tail(upper.ln())
1398 } else {
1399 let q = lower - 0.5;
1400 let r = q * q;
1401 poly(&A, r) * q / (poly(&B, r) * r + 1.0)
1402 }
1403}
1404
1405fn theta_with(m: &Model, b: [f64; P], feats: &[Feat], units: &[Unit], lv: &Levels, seed: u64) -> Theta {
1410 theta_by(m, b, feats, units, lv, |(mean, sd), h| mean + sd * normal(&mut Rng::new(mix(seed, h))))
1411}
1412
1413fn theta_mean(m: &Model, feats: &[Feat], units: &[Unit], lv: &Levels) -> Theta {
1419 let spec = coefs();
1420 let b = std::array::from_fn(|i| {
1421 let x = m.beta.get(i).copied().unwrap_or(0.0);
1422 if spec[i].nonpos { x.min(0.0) } else { x }
1423 });
1424 theta_by(m, b, feats, units, lv, |(mean, _), _| mean)
1425}
1426
1427fn theta_by(m: &Model, b: [f64; P], feats: &[Feat], units: &[Unit], lv: &Levels, level: impl Fn((f64, f64), u64) -> f64) -> Theta {
1430 let effect = |map: &BTreeMap<String, (f64, f64)>, name: &str, spread: f64, h: u64| level(map.get(name).copied().unwrap_or((0.0, spread)), h);
1431 let shared = effect(&m.sessions, lv.name, m.spread[SESSION], lv.session) + effect(&m.judges, &m.judge, m.spread[JUDGE], lv.judge);
1432 let base = feats
1433 .iter()
1434 .zip(units)
1435 .zip(&lv.lessons)
1436 .map(|((f, u), h)| dot(&b, &design(f, Route::Free, 1)) + shared + effect(&m.lessons, u.lesson, m.spread[LESSON], *h))
1437 .collect();
1438 Theta { b, base }
1439}
1440
1441fn theta(m: &Model, feats: &[Feat], units: &[Unit], lv: &Levels, seed: u64) -> Theta {
1443 theta_with(m, draw_beta(m, seed), feats, units, lv, seed)
1444}
1445
1446struct Valuer<'a> {
1448 t: &'a Theta,
1449 feats: &'a [Feat],
1450 rates: &'a Rates,
1451}
1452
1453impl Valuer<'_> {
1454 fn n(&self) -> usize {
1455 self.feats.len()
1456 }
1457
1458 fn stop(&self) -> bool {
1459 self.feats.first().is_some_and(|f| f.stop)
1460 }
1461
1462 fn p_not(&self, j: usize) -> f64 {
1464 let b = &self.t.b;
1465 sigmoid(self.t.base[j] + b[NOT] + b[NOT_G] * (f64::from(self.feats[j].grade) - 2.0))
1466 }
1467
1468 fn ask(&self, j: usize, m: usize) -> f64 {
1469 let (b, f) = (&self.t.b, &self.feats[j]);
1470 let eta = self.t.base[j]
1471 + b[ASK]
1472 + b[ASK_M] * (m.max(1) as f64).ln()
1473 + b[ASK_STOP] * f64::from(u8::from(f.stop))
1474 + b[ASK_G3] * f64::from(u8::from(f.grade == 3));
1475 let rate = match (self.stop(), f.three) {
1480 (true, _) => 1.0,
1481 (false, true) => self.rates.ask_live,
1482 (false, false) => self.rates.free_live,
1483 };
1484 rate * (sigmoid(eta) - self.p_not(j))
1485 }
1486
1487 fn free(&self, j: usize, m: usize) -> f64 {
1488 let rate = if self.stop() { self.rates.free_stop } else { self.rates.free_live };
1489 rate * (sigmoid(self.t.base[j] + self.t.b[FREE_M] * (m.max(1) as f64).ln()) - self.p_not(j))
1490 }
1491
1492 fn value(&self, routes: &[Route]) -> f64 {
1498 let m_q = routes.iter().filter(|r| **r == Route::Ask).count();
1499 let m_f = routes.iter().filter(|r| **r == Route::Free).count();
1500 routes
1501 .iter()
1502 .enumerate()
1503 .map(|(j, r)| match r {
1504 Route::Ask => self.ask(j, m_q),
1505 Route::Free => self.free(j, m_f),
1506 Route::Placebo | Route::Not => 0.0,
1507 })
1508 .sum()
1509 }
1510}
1511
1512fn forced(f: &Feat) -> bool {
1515 f.stop && f.grade == 3
1516}
1517
1518struct Table {
1521 n: usize,
1522 ask: Vec<f64>,
1526 free: Vec<f64>,
1527 forced: Vec<bool>,
1528 nofree: Vec<bool>,
1529}
1530
1531impl Table {
1532 fn of(v: &Valuer, nofree: &[bool]) -> Table {
1535 let n = v.n();
1536 let mut ask = vec![0.0; (n + 1) * n];
1537 let mut free = vec![0.0; (n + 1) * n];
1538 for m in 1..=n {
1539 for j in 0..n {
1540 ask[m * n + j] = v.ask(j, m);
1541 free[m * n + j] = v.free(j, m);
1542 }
1543 }
1544 Table { n, ask, free, forced: v.feats.iter().map(forced).collect(), nofree: (0..n).map(|j| nofree.get(j).copied().unwrap_or(false)).collect() }
1545 }
1546
1547 #[cfg(test)]
1550 fn value(&self, routes: &[Route]) -> f64 {
1551 let m_q = routes.iter().filter(|r| **r == Route::Ask).count();
1552 let m_f = routes.iter().filter(|r| **r == Route::Free).count();
1553 let n = self.n;
1554 routes
1555 .iter()
1556 .enumerate()
1557 .map(|(j, r)| match r {
1558 Route::Ask => self.ask[m_q * n + j],
1559 Route::Free => self.free[m_f * n + j],
1560 Route::Placebo | Route::Not => 0.0,
1561 })
1562 .sum()
1563 }
1564}
1565
1566#[derive(Debug, Clone, Copy, PartialEq)]
1568struct Total(f64);
1569
1570impl Eq for Total {}
1571
1572impl PartialOrd for Total {
1573 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1574 Some(self.cmp(other))
1575 }
1576}
1577
1578impl Ord for Total {
1579 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1580 self.0.total_cmp(&other.0)
1581 }
1582}
1583
1584fn top_sums(values: impl Iterator<Item = f64>, k: usize, out: &mut Vec<f64>, heap: &mut std::collections::BinaryHeap<std::cmp::Reverse<Total>>) {
1587 out.clear();
1588 heap.clear();
1589 out.push(if k == 0 { 0.0 } else { f64::NEG_INFINITY });
1590 let mut sum = 0.0;
1591 for x in values {
1592 if k > 0 {
1593 if heap.len() < k {
1594 heap.push(std::cmp::Reverse(Total(x)));
1595 sum += x;
1596 } else if let Some(min) = heap.peek().map(|r| r.0.0)
1597 && x > min
1598 {
1599 heap.pop();
1600 heap.push(std::cmp::Reverse(Total(x)));
1601 sum += x - min;
1602 }
1603 }
1604 out.push(if heap.len() < k { f64::NEG_INFINITY } else { sum });
1605 }
1606}
1607
1608fn solve(t: &Table, rail: usize, cap: usize) -> Vec<Route> {
1616 let n = t.n;
1617 let nf_forced = t.forced.iter().filter(|x| **x).count();
1618 let open: Vec<usize> = (0..n).filter(|&j| !t.forced[j]).collect();
1619 let held = open.iter().filter(|&&j| t.nofree[j]).count();
1622 let q_max = rail.max(nf_forced).min(n);
1623 let f_max = cap.min(open.len() - held);
1624 let ask = |m: usize, j: usize| if m == 0 { 0.0 } else { t.ask[m * n + j] };
1626 let free = |m: usize, j: usize| if m == 0 { 0.0 } else { t.free[m * n + j] };
1627 let mut order = open.clone();
1628 let mut keys = vec![0.0; n];
1629 let (mut pre, mut suf) = (Vec::with_capacity(n + 1), Vec::with_capacity(n + 1));
1630 let mut heap = std::collections::BinaryHeap::with_capacity(n + 1);
1631 let sort = |order: &mut Vec<usize>, keys: &mut Vec<f64>, mq: usize, mf: usize| {
1632 for &j in &open {
1633 keys[j] = if t.nofree[j] { f64::INFINITY } else { ask(mq, j) - free(mf, j) };
1634 }
1635 order.clear();
1636 order.extend_from_slice(&open);
1637 order.sort_by(|&a, &b| keys[b].total_cmp(&keys[a]).then(a.cmp(&b)));
1638 };
1639 let mut best: Option<(f64, usize, usize, usize)> = None;
1640 for mq in nf_forced..=q_max {
1641 let k = mq - nf_forced;
1642 if k > open.len() {
1643 break;
1644 }
1645 let fixed: f64 = (0..n).filter(|&j| t.forced[j]).map(|j| ask(mq, j)).sum();
1646 for mf in 0..=f_max.min(open.len() - k) {
1647 sort(&mut order, &mut keys, mq, mf);
1648 top_sums(order.iter().map(|&j| ask(mq, j)), k, &mut pre, &mut heap);
1649 top_sums(order.iter().rev().map(|&j| free(mf, j)), mf, &mut suf, &mut heap);
1650 for split in k.max(held)..=open.len() - mf {
1651 let total = fixed + pre[split] + suf[open.len() - split];
1652 if best.is_none_or(|b| total > b.0) {
1653 best = Some((total, mq, mf, split));
1654 }
1655 }
1656 }
1657 }
1658 let mut routes = vec![Route::Not; n];
1659 for j in (0..n).filter(|&j| t.forced[j]) {
1660 routes[j] = Route::Ask;
1661 }
1662 let Some((_, mq, mf, split)) = best else { return routes };
1663 sort(&mut order, &mut keys, mq, mf);
1664 let pick = |within: &[usize], gain: &dyn Fn(usize) -> f64, k: usize| -> Vec<usize> {
1665 let mut c = within.to_vec();
1666 c.sort_by(|&a, &b| gain(b).total_cmp(&gain(a)).then(a.cmp(&b)));
1667 c.truncate(k);
1668 c
1669 };
1670 for j in pick(&order[..split], &|j| ask(mq, j), mq - nf_forced) {
1671 routes[j] = Route::Ask;
1672 }
1673 for j in pick(&order[split..], &|j| free(mf, j), mf) {
1674 routes[j] = Route::Free;
1675 }
1676 routes
1677}
1678
1679#[must_use]
1682pub fn fixed_routes(units: &[Unit], had: &[String]) -> Vec<Route> {
1683 let ls: Vec<Challenge> = units.iter().map(|u| Challenge { id: u.lesson.to_string(), ..Default::default() }).collect();
1684 let applied: Vec<(&Challenge, u8)> = ls.iter().zip(units).map(|(c, u)| (c, u.grade)).collect();
1685 let (ask, free) = conscience::graded_without(&applied, None, had);
1686 let holds = |v: &[&Challenge], c: &Challenge| v.iter().any(|x| std::ptr::eq(*x, c));
1687 ls.iter()
1688 .map(|c| match (holds(&ask, c), holds(&free, c)) {
1689 (true, _) => Route::Ask,
1690 (_, true) => Route::Free,
1691 _ => Route::Not,
1692 })
1693 .collect()
1694}
1695
1696fn nofree_of(units: &[Unit], had: &[String]) -> Vec<bool> {
1698 units.iter().map(|u| had.iter().any(|h| h == u.lesson)).collect()
1699}
1700
1701#[derive(Debug, Clone, Copy)]
1703pub struct Check<'a> {
1704 pub session: &'a str,
1705 pub stop: bool,
1706 pub tokens: u32,
1708}
1709
1710pub const FIXED: &str = "fixed";
1714pub const SIZE: &str = "size";
1716pub const GATE: &str = "gate";
1719pub const EVIDENCE: &str = "evidence";
1723pub const EXPERIMENT: &str = "experiment";
1726pub const EXPERIMENT_CONTROL: &str = "experiment_control";
1729
1730#[derive(Debug, Clone, PartialEq)]
1741pub struct Decision {
1742 pub routes: Vec<Route>,
1743 pub p: Vec<f64>,
1744 pub pf: Vec<f64>,
1745 pub source: &'static str,
1746 pub gate: f64,
1747 pub risk: f64,
1748 pub xk: Option<u32>,
1749 pub xs: Option<u32>,
1750}
1751
1752fn lower_quantile(v: &mut [f64]) -> f64 {
1754 if v.is_empty() {
1755 return 0.0;
1756 }
1757 v.sort_by(f64::total_cmp);
1758 v[((DELTA * v.len() as f64).ceil() as usize).clamp(1, v.len()) - 1]
1759}
1760
1761#[cfg(test)]
1764#[must_use]
1765pub(crate) fn remaining(gate: &Gate) -> f64 {
1766 gate.allowance + lower_quantile(&mut gate.gain.clone())
1767}
1768
1769fn allowance_at(gate: &Gate, alpha: f64) -> f64 {
1772 if alpha > 0.0 { gate.allowance * (alpha / ALPHA) } else { 0.0 }
1775}
1776
1777#[must_use]
1780pub fn remaining_at(gate: &Gate, alpha: f64) -> f64 {
1781 let (allowance, ledger) = gate_parts(gate, alpha);
1782 (allowance + ledger).max(0.0)
1783}
1784
1785#[must_use]
1790pub fn gate_parts(gate: &Gate, alpha: f64) -> (f64, f64) {
1791 (allowance_at(gate, alpha), lower_quantile(&mut gate.gain.clone()))
1792}
1793
1794fn gate_seed(fit: &Fit, k: usize) -> u64 {
1797 mix(hash(&[b"gate", fit.key.as_bytes()]), k as u64)
1798}
1799
1800struct Draws<'a> {
1803 thetas: Vec<Theta>,
1804 feats: Vec<Feat>,
1805 rates: &'a Rates,
1806 base: Vec<f64>,
1807}
1808
1809impl<'a> Draws<'a> {
1810 fn of(model: &Model, fit: &'a Fit, units: &[Unit], check: &Check, lv: &Levels, base: &[Route]) -> Draws<'a> {
1811 let feats = Feat::of(units, check.stop, check.tokens);
1812 let thetas: Vec<Theta> = (0..DRAWS).map(|k| theta(model, &feats, units, lv, gate_seed(fit, k))).collect();
1813 let mut d = Draws { thetas, feats, rates: &fit.rates, base: Vec::new() };
1814 d.base = (0..DRAWS).map(|k| d.value(k, base)).collect();
1815 d
1816 }
1817
1818 fn value(&self, k: usize, routes: &[Route]) -> f64 {
1820 Valuer { t: &self.thetas[k], feats: &self.feats, rates: self.rates }.value(routes)
1821 }
1822
1823 fn gains(&self, routes: &[Route]) -> Vec<f64> {
1825 self.base.iter().enumerate().map(|(k, b)| self.value(k, routes) - b).collect()
1826 }
1827}
1828
1829fn evident(model: &Model, d: &Draws, units: &[Unit], lv: &Levels, base: &[Route], nofree: &[bool]) -> Option<Vec<Route>> {
1833 let t = theta_mean(model, &d.feats, units, lv);
1834 let best = solve(&Table::of(&Valuer { t: &t, feats: &d.feats, rates: d.rates }, nofree), crate::attention::RAIL, conscience::CAP);
1835 (best != base && lower_quantile(&mut d.gains(&best)) > 0.0).then_some(best)
1836}
1837
1838pub(crate) fn tested(units: &[Unit], base: &[Route], had: &[String]) -> Vec<usize> {
1843 let mut order: Vec<usize> =
1844 (0..units.len()).filter(|&j| units[j].grade != 3 && matches!(base[j], Route::Ask | Route::Free) && !had.iter().any(|h| h == units[j].lesson)).collect();
1845 order.sort_by_key(|&j| std::cmp::Reverse(units[j].grade));
1846 order
1847}
1848
1849fn test_routes(base: &[Route], order: &[usize], s: usize) -> Vec<Route> {
1852 let mut routes = base.to_vec();
1853 for (i, &j) in order.iter().enumerate() {
1854 routes[j] = if i < s { Route::Ask } else { Route::Free };
1855 }
1856 routes
1857}
1858
1859fn draw_count(seed: u64, k: usize) -> usize {
1863 (hash(&[b"question-count", &seed.to_le_bytes()]) % (k as u64 + 1)) as usize
1864}
1865
1866#[must_use]
1882pub fn choose(units: &[Unit], check: &Check, fit: &Fit, gate: &Gate, alpha: f64, seed: u64, had: &[String]) -> Decision {
1883 let base = fixed_routes(units, had);
1884 let fixed = |source: &'static str| Decision {
1885 p: share(&[&base], Route::Ask),
1886 pf: share(&[&base], Route::Free),
1887 routes: base.clone(),
1888 source,
1889 gate: 0.0,
1890 risk: 0.0,
1891 xk: None,
1892 xs: None,
1893 };
1894 let Some(model) = fit.model.as_ref().filter(|m| m.current()) else { return fixed(FIXED) };
1895 if gate.allowance <= 0.0 || gate.gain.len() != DRAWS || units.is_empty() {
1896 return fixed(FIXED);
1897 }
1898 if units.len() > SOLVE_MAX {
1899 return fixed(SIZE);
1900 }
1901 let lv = Levels::of(check.session, &model.judge, units);
1902 let d = Draws::of(model, fit, units, check, &lv, &base);
1903 let allowance = allowance_at(gate, alpha);
1904 let held = lower_quantile(&mut gate.gain.clone());
1905 let remaining = remaining_at(gate, alpha);
1906 let admit = |routes: &[Route]| -> Option<f64> {
1910 if routes == base {
1911 return Some(0.0);
1912 }
1913 let mut after: Vec<f64> = d.gains(routes).iter().zip(&gate.gain).map(|(x, g)| g + x).collect();
1914 let q = lower_quantile(&mut after);
1915 (q >= -allowance).then_some((held - q).max(0.0))
1916 };
1917 if let Some(best) = evident(model, &d, units, &lv, &base, &nofree_of(units, had)) {
1918 return match admit(&best) {
1919 Some(risk) => Decision {
1920 p: share(&[&best], Route::Ask),
1921 pf: share(&[&best], Route::Free),
1922 routes: best,
1923 source: EVIDENCE,
1924 gate: remaining,
1925 risk,
1926 xk: None,
1927 xs: None,
1928 },
1929 None => Decision { gate: remaining, ..fixed(GATE) },
1930 };
1931 }
1932 let order = tested(units, &base, had);
1933 if check.stop || alpha <= 0.0 || order.is_empty() {
1934 return Decision { gate: remaining, ..fixed(EVIDENCE) };
1935 }
1936 let k = order.len();
1939 let arms: Vec<(Vec<Route>, Option<f64>)> = (0..=k)
1940 .map(|s| {
1941 let routes = test_routes(&base, &order, s);
1942 let risk = admit(&routes);
1943 (if risk.is_some() { routes } else { base.clone() }, risk)
1944 })
1945 .collect();
1946 let played: Vec<&Vec<Route>> = arms.iter().map(|(r, _)| r).collect();
1947 let s = draw_count(seed, k);
1948 let (routes, risk) = &arms[s];
1949 let source = match risk {
1950 None => GATE,
1951 Some(_) if *routes == base => EXPERIMENT_CONTROL,
1952 Some(_) => EXPERIMENT,
1953 };
1954 let count = |x: usize| Some(u32::try_from(x).unwrap_or(u32::MAX));
1955 Decision {
1956 p: share(&played, Route::Ask),
1957 pf: share(&played, Route::Free),
1958 routes: routes.clone(),
1959 source,
1960 gate: remaining,
1961 risk: risk.unwrap_or(0.0),
1962 xk: count(k),
1963 xs: count(s),
1964 }
1965}
1966
1967#[must_use]
1976pub fn decide_on_evidence(units: &[Unit], check: &Check, fit: &Fit, had: &[String]) -> Vec<Route> {
1977 let base = fixed_routes(units, had);
1978 let Some(model) = fit.model.as_ref().filter(|m| m.current()) else { return base };
1979 if units.is_empty() || units.len() > SOLVE_MAX {
1980 return base;
1981 }
1982 let lv = Levels::of(check.session, &model.judge, units);
1983 let d = Draws::of(model, fit, units, check, &lv, &base);
1984 evident(model, &d, units, &lv, &base, &nofree_of(units, had)).unwrap_or(base)
1985}
1986
1987fn share(draws: &[&Vec<Route>], route: Route) -> Vec<f64> {
1990 let n = draws.first().map_or(0, |d| d.len());
1991 (0..n).map(|j| crate::outcome::round_p(draws.iter().filter(|d| d[j] == route).count() as f64 / draws.len() as f64)).collect()
1992}
1993
1994pub fn ledger_add_without(fit: &Fit, gate: &mut Gate, check: &Check, units: &[Unit], routes: &[Route], had: &[String]) {
2000 let Some(model) = fit.model.as_ref().filter(|m| m.current()) else { return };
2001 let betas: Vec<[f64; P]> = (0..DRAWS).map(|k| draw_beta(model, gate_seed(fit, k))).collect();
2002 add_with(fit, &betas, gate, check, units, routes, had);
2003}
2004
2005fn add_with(fit: &Fit, betas: &[[f64; P]], gate: &mut Gate, check: &Check, units: &[Unit], routes: &[Route], had: &[String]) {
2008 let Some(model) = fit.model.as_ref().filter(|m| m.current()) else { return };
2009 if units.is_empty() || units.len() != routes.len() {
2010 return;
2011 }
2012 if gate.gain.len() != DRAWS {
2013 gate.gain = vec![0.0; DRAWS];
2014 }
2015 let base = fixed_routes(units, had);
2016 let routes: Vec<Route> = routes.iter().map(|r| if *r == Route::Placebo { Route::Not } else { *r }).collect();
2018 let feats = Feat::of(units, check.stop, check.tokens);
2019 let lv = Levels::of(check.session, &model.judge, units);
2020 let mut mean = 0.0;
2021 for (k, (g, b)) in gate.gain.iter_mut().zip(betas).enumerate() {
2022 let t = theta_with(model, *b, &feats, units, &lv, gate_seed(fit, k));
2023 let v = Valuer { t: &t, feats: &feats, rates: &fit.rates };
2024 let fixed = v.value(&base);
2025 *g += v.value(&routes) - fixed;
2026 mean += fixed / DRAWS as f64;
2027 }
2028 gate.allowance += ALPHA * mean.max(0.0);
2029 gate.checks += 1;
2030 if routes != base {
2031 gate.deviated += 1;
2032 }
2033}
2034
2035#[must_use]
2038pub fn ledger(s: &Summary) -> Gate {
2039 let mut gate = Gate::default();
2040 let Some(model) = s.fit.model.as_ref().filter(|m| m.current()) else { return gate };
2041 let betas: Vec<[f64; P]> = (0..DRAWS).map(|k| draw_beta(model, gate_seed(&s.fit, k))).collect();
2042 for c in s.checks.iter().filter(|c| c.learned) {
2043 let (units, _, routes) = check_view(s, c);
2044 let session = s.sessions.get(c.tick as usize).map_or("", |x| x.id.as_str());
2045 add_with(&s.fit, &betas, &mut gate, &Check { session, stop: c.stop, tokens: c.tokens }, &units, &routes, &logged_had(s, c));
2046 }
2047 gate
2048}
2049
2050#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
2052pub struct Monitor {
2053 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2055 pub reverted: bool,
2056 #[serde(default, skip_serializing_if = "is_default")]
2058 pub sessions: u32,
2059 #[serde(default, skip_serializing_if = "is_default")]
2060 pub units: u32,
2061 #[serde(default, skip_serializing_if = "is_default")]
2065 pub mean: f64,
2066 #[serde(default, skip_serializing_if = "Option::is_none")]
2067 pub ucb: Option<f64>,
2068 #[serde(default, skip_serializing_if = "is_default")]
2075 pub labelled: u32,
2076 #[serde(default, skip_serializing_if = "is_default")]
2077 pub ess: f64,
2078 #[serde(default, skip_serializing_if = "is_default")]
2079 pub max_weight: f64,
2080 #[serde(default, skip_serializing_if = "is_default")]
2081 pub truncated: u32,
2082}
2083
2084#[must_use]
2093pub fn monitor(s: &Summary) -> Monitor {
2094 let spec = coefs();
2095 let d = Data::of(s, false);
2096 let Some(model) = &s.fit.model else { return Monitor::default() };
2097 let sd: [f64; P] = std::array::from_fn(|k| model.sd.get(k).copied().unwrap_or(spec[k].sd));
2098 let spread = model.spread;
2099 let folds: Vec<Option<Est<P, FAMILIES>>> = (0..2u32)
2100 .map(|half| {
2101 let train: Vec<Obs<P, FAMILIES>> =
2102 d.weighted(s.sessions.len() as u32, s.fit.half_life).into_iter().zip(&d.tick).filter(|(_, t)| *t % 2 != half).map(|(o, _)| o).collect();
2103 map(&train, d.levels, &spec, &sd, &spread, None)
2104 })
2105 .collect();
2106 struct Term {
2108 tick: u32,
2109 diff: [f64; ARMS],
2110 p_hat: [f64; ARMS],
2111 taken: usize,
2112 y: Option<f64>,
2113 w: f64,
2114 vb: f64,
2115 }
2116 let mut terms: Vec<Term> = Vec::new();
2117 for c in s.checks.iter().filter(|c| c.learned) {
2118 let Some(est) = &folds[(c.tick % 2) as usize] else { continue };
2119 let (units, feats, routes) = check_view(s, c);
2120 let base = fixed_routes(&units, &logged_had(s, c));
2122 let (m_q, m_f) = (usize::from(c.m_q).max(1), usize::from(c.m_f).max(1));
2123 for (j, f) in feats.iter().enumerate() {
2124 let i = c.first as usize + j;
2125 let a = &s.applies[i];
2126 let lv = d.lv_of.get(&i).copied().unwrap_or([None; FAMILIES]);
2127 let eta = |route: Route, m: usize| -> f64 {
2129 dot(&est.beta, &design(f, route, m))
2130 + lv[1].map_or(0.0, |l| est.u[LESSON].get(l).copied().unwrap_or(0.0))
2131 + lv[2].map_or(0.0, |l| est.u[JUDGE].get(l).copied().unwrap_or(0.0))
2132 };
2133 let p_hat = [sigmoid(eta(Route::Ask, m_q)), sigmoid(eta(Route::Free, m_f)), sigmoid(eta(Route::Not, 1))];
2134 let mu = [a.p, a.pf, (1.0 - a.p - a.pf).max(0.0)];
2135 let (taken, fixed) = (routes[j].arm(), base[j].arm());
2136 let diff: [f64; ARMS] = std::array::from_fn(|x| mu[x] - f64::from(u8::from(x == fixed)));
2137 let y = match a.label {
2138 Label::Changed => Some(1.0),
2139 Label::Ignored | Label::NotArisen => Some(0.0),
2140 Label::Unclear | Label::Remains | Label::Open | Label::Censored => None,
2141 };
2142 let psi = if a.route == Route::Placebo { a.psi.max(PSI_FLOOR) } else { 1.0 };
2145 let judged_p = d.judged_p.get(&i).copied().unwrap_or(1.0).max(1.0 / MAX_IPW);
2146 let w = if y.is_some() { 1.0 / (mu[taken].max(1.0 / DRAWS as f64) * judged_p * psi) } else { 0.0 };
2147 let vb = p_hat[fixed] - p_hat[2];
2148 terms.push(Term { tick: c.tick, diff, p_hat, taken, y, w, vb });
2149 }
2150 }
2151 let labelled: Vec<f64> = terms.iter().filter(|t| t.y.is_some()).map(|t| t.w).collect();
2152 if terms.is_empty() || labelled.is_empty() {
2153 return Monitor::default();
2154 }
2155 let cap = (labelled.len() as f64).sqrt() * labelled.iter().sum::<f64>() / labelled.len() as f64;
2156 let mut norm = [0.0f64; ARMS];
2157 for t in &terms {
2158 norm[t.taken] += t.w.min(cap);
2159 }
2160 let count = terms.len() as f64;
2161 let norm = norm.map(|x| if x > 0.0 { x / count } else { 1.0 });
2162 let mut routed = [0usize; ARMS];
2166 for t in terms.iter().filter(|t| t.y.is_some() && t.diff[t.taken] != 0.0) {
2167 routed[t.taken] += 1;
2168 }
2169 let residual = routed.map(|n| n >= ROUTE_LABELS_MIN);
2170 let mut per: BTreeMap<u32, (f64, usize)> = BTreeMap::new();
2171 for t in &terms {
2172 let w = t.w.min(cap) / norm[t.taken];
2173 let r = |a: usize| if a == t.taken && residual[a] { t.y.map_or(0.0, |y| w * (y - t.p_hat[a])) } else { 0.0 };
2174 let dr: f64 = (0..ARMS).map(|a| t.diff[a] * (t.p_hat[a] + r(a))).sum();
2175 let e = per.entry(t.tick).or_insert((0.0, 0));
2176 e.0 += dr + ALPHA * t.vb;
2177 e.1 += 1;
2178 }
2179 let z: Vec<f64> = per.values().map(|(x, n)| x / *n as f64).collect();
2180 let mean = z.iter().sum::<f64>() / z.len() as f64;
2181 let ucb = Some(upper_bound(&z)).filter(|u| u.is_finite());
2182 let moves = |t: &&Term| t.y.is_some() && residual[t.taken] && t.diff[t.taken] != 0.0;
2186 let used: Vec<f64> = terms.iter().filter(moves).map(|t| t.w.min(cap) / norm[t.taken]).collect();
2187 let (sum, sq) = used.iter().fold((0.0, 0.0), |(s, q), w| (s + w, q + w * w));
2188 let ess = if sq > 0.0 { sum * sum / sq } else { 0.0 };
2189 let max_weight = if sum > 0.0 { used.iter().copied().fold(0.0, f64::max) * used.len() as f64 / sum } else { 0.0 };
2190 Monitor {
2191 reverted: ucb.is_some_and(|u| u < 0.0),
2192 sessions: z.len() as u32,
2193 units: terms.len() as u32,
2194 mean,
2195 ucb,
2196 labelled: used.len() as u32,
2197 ess,
2198 max_weight,
2199 truncated: terms.iter().filter(moves).filter(|t| t.w > cap).count() as u32,
2200 }
2201}
2202
2203fn upper_bound(x: &[f64]) -> f64 {
2212 let log = (1.0 / DELTA).ln();
2213 let shape = 2.0 * log + (1.0 + 2.0 * log).ln();
2214 let (mut sum, mut sum_sq) = (0.0, 0.0);
2215 let mut best = f64::INFINITY;
2216 for (i, &xi) in x.iter().enumerate() {
2217 let t = (i + 1) as f64;
2218 sum += xi;
2219 sum_sq += xi * xi;
2220 if i + 1 < CS_MIN.max(2) {
2221 continue;
2222 }
2223 let mean = sum / t;
2224 let var = ((sum_sq - t * mean * mean) / (t - 1.0)).max(f64::MIN_POSITIVE);
2225 let (v, rho) = (t * var, var * CS_SESSIONS / shape);
2226 best = best.min(mean + ((v + rho) * ((1.0 + v / rho).ln() + 2.0 * log)).sqrt() / t);
2227 }
2228 best
2229}
2230
2231#[must_use]
2233pub fn session_seed(session: &str) -> u64 {
2234 hash(&[b"pool", session.as_bytes()])
2235}
2236
2237#[must_use]
2239pub fn pool_grid() -> Vec<usize> {
2240 POOL_STEPS.iter().map(|&i| (crate::whole::TOKENS as f64 * 2f64.powf(f64::from(i) / 2.0)).round() as usize).collect()
2241}
2242
2243fn pool_value(pool: &PoolFit, tokens: usize, seed: u64) -> f64 {
2247 let mut rng = Rng::new(seed);
2248 let shown = tokens as f64 / pool.per_lesson.max(1.0);
2249 let mut applies = 0.0;
2250 for (b, &(a, bb)) in pool.yields.iter().enumerate() {
2251 let (lo, hi) = ((1u64 << b) - 1, (1u64 << (b + 1)) - 1);
2252 let inside = (shown.min(hi as f64) - lo as f64).max(0.0);
2253 applies += inside * if inside > 0.0 { beta_draw(a, bb, &mut rng) } else { 0.0 };
2254 }
2255 let slope = (pool.slope.0 + pool.slope.1 * normal(&mut rng)).min(0.0);
2256 let not = pool.not.0 + pool.not.1 * normal(&mut rng);
2257 let eta = pool.eta + slope * (tokens as f64 / crate::whole::TOKENS as f64).ln();
2258 applies * (sigmoid(eta) - sigmoid(eta + not)).max(0.0)
2259}
2260
2261fn pool_feasible(pool: &PoolFit, tokens: usize, limit_ms: Option<u64>, lambda: f64) -> bool {
2266 let baseline = crate::whole::TOKENS;
2267 let bins_to = |t: usize| -> usize {
2268 let shown = (t as f64 / pool.per_lesson.max(1.0)).ceil() as u64;
2269 (u64::BITS - shown.leading_zeros()) as usize
2270 };
2271 let known = pool.per_lesson > 0.0 && pool.yields.len() >= bins_to(tokens.max(baseline));
2272 if tokens > baseline {
2273 let (a, b, q) = pool.latency;
2274 let fits = limit_ms.is_some_and(|l| a + b * tokens as f64 + q <= l as f64);
2275 let observed = f64::from(pool.latency_reach) >= LATENCY_NEAR * tokens as f64;
2276 return known && fits && observed && pool.latency_n as usize >= LATENCY_MIN;
2277 }
2278 tokens == baseline || known || lambda > 0.0
2279}
2280
2281struct PoolDraws {
2284 grid: Vec<usize>,
2285 shared: Vec<Vec<f64>>,
2286}
2287
2288impl PoolDraws {
2289 fn of(pool: &PoolFit) -> PoolDraws {
2290 let grid = pool_grid();
2291 let shared = grid.iter().map(|&t| (0..DRAWS).map(|k| pool_value(pool, t, mix(hash(&[b"pool-gate"]), k as u64))).collect()).collect();
2292 PoolDraws { grid, shared }
2293 }
2294
2295 fn choose(&self, play: &[f64], feasible: &[bool], lambda: f64) -> usize {
2300 let baseline = crate::whole::TOKENS;
2301 let Some(b) = self.grid.iter().position(|&t| t == baseline) else { return baseline };
2302 let admits = |i: usize| -> bool {
2303 let cost = lambda * (self.grid[i] as f64 - baseline as f64);
2304 let mut gain: Vec<f64> = self.shared[i].iter().zip(&self.shared[b]).map(|(x, y)| x - y - cost).collect();
2305 let q = lower_quantile(&mut gain);
2306 i == b || if self.grid[i] > baseline { q > 0.0 } else { q >= 0.0 }
2307 };
2308 (0..self.grid.len())
2309 .filter(|&i| feasible[i] && admits(i))
2310 .map(|i| (play[i] - lambda * self.grid[i] as f64, self.grid[i]))
2311 .fold(None, |best: Option<(f64, usize)>, x| if best.is_none_or(|b| x.0 > b.0) { Some(x) } else { best })
2312 .map_or(baseline, |(_, t)| t)
2313 }
2314
2315 fn play(&self, pool: &PoolFit, seed: u64) -> Vec<f64> {
2317 self.grid.iter().map(|&t| pool_value(pool, t, seed)).collect()
2318 }
2319}
2320
2321#[must_use]
2328pub fn pool_tokens(fit: &Fit, active: bool, limit_ms: Option<u64>, seed: u64) -> usize {
2329 let baseline = crate::whole::TOKENS;
2330 if !active || fit.pool.yields.is_empty() && fit.lambda == 0.0 {
2331 return baseline;
2332 }
2333 let draws = PoolDraws::of(&fit.pool);
2334 let feasible: Vec<bool> = draws.grid.iter().map(|&t| pool_feasible(&fit.pool, t, limit_ms, fit.lambda)).collect();
2335 draws.choose(&draws.play(&fit.pool, seed), &feasible, fit.lambda)
2336}
2337
2338#[must_use]
2347pub fn price(s: &Summary, fit: &Fit, budget: f64) -> f64 {
2348 let sessions: Vec<(&str, u32)> = s.sessions.iter().rev().take(FIT_SESSIONS).filter(|x| x.checks > 0).map(|x| (x.id.as_str(), x.checks)).collect();
2349 if sessions.is_empty() || budget <= 0.0 {
2350 return 0.0;
2351 }
2352 let draws = PoolDraws::of(&fit.pool);
2353 let plays: Vec<Vec<f64>> = sessions.iter().map(|(id, _)| draws.play(&fit.pool, session_seed(id))).collect();
2354 let spend = |lambda: f64| -> f64 {
2355 let feasible: Vec<bool> = draws.grid.iter().map(|&t| pool_feasible(&fit.pool, t, Some(u64::MAX), lambda)).collect();
2357 sessions.iter().zip(&plays).map(|((_, n), play)| f64::from(*n) * draws.choose(play, &feasible, lambda) as f64).sum::<f64>() / sessions.len() as f64
2358 };
2359 if spend(0.0) <= budget {
2360 return 0.0;
2361 }
2362 let mut hi = 1.0 / crate::whole::TOKENS as f64;
2364 for _ in 0..PRICE_STEPS {
2365 if spend(hi) <= budget {
2366 break;
2367 }
2368 hi *= 2.0;
2369 }
2370 let mut lo = 0.0;
2371 for _ in 0..PRICE_STEPS {
2372 let mid = 0.5 * (lo + hi);
2373 if spend(mid) <= budget { hi = mid } else { lo = mid }
2374 }
2375 hi
2376}
2377
2378#[cfg(test)]
2379mod tests {
2380 use super::*;
2381 use crate::outcome::{self, Outcome};
2382 use crate::select::summary::{self, Summary};
2383
2384 fn unit(lesson: &str, grade: u8, rank: usize) -> Unit<'_> {
2385 Unit { lesson, grade, rank }
2386 }
2387
2388 fn tables(n: usize) -> Vec<Vec<u8>> {
2390 let mut out = vec![Vec::new()];
2391 for len in 1..=n {
2392 out.extend((0..3usize.pow(len as u32)).map(|mut x| {
2393 (0..len)
2394 .map(|_| {
2395 let g = (x % 3) as u8 + 1;
2396 x /= 3;
2397 g
2398 })
2399 .collect()
2400 }));
2401 }
2402 out
2403 }
2404
2405 #[derive(Clone, Copy)]
2408 struct Truth {
2409 b: [f64; P],
2410 lift: f64,
2411 }
2412
2413 impl Truth {
2414 fn p(&self, f: &Feat, route: Route, m: usize) -> f64 {
2415 sigmoid(dot(&self.b, &design(f, route, m)) + if f.stop && f.three { self.lift } else { 0.0 })
2416 }
2417 }
2418
2419 struct Sim {
2423 rng: Rng,
2424 lines: Vec<Outcome>,
2425 ms: i64,
2426 }
2427
2428 type Router<'a> = dyn FnMut(&[u8], bool, &mut Rng) -> (Vec<Route>, Vec<f64>, Vec<f64>, &'static str) + 'a;
2429
2430 impl Sim {
2431 fn new(seed: u64) -> Sim {
2432 Sim { rng: Rng::new(seed), lines: Vec::new(), ms: 0 }
2433 }
2434
2435 fn session(&mut self, session: &str, checks: usize, lessons: usize, truth: &Truth, route: &mut Router) {
2436 for c in 0..checks {
2437 let n = 1 + (self.rng.next_u64() % 4) as usize;
2438 let stop = c % 4 == 3;
2439 let grades: Vec<u8> = (0..n).map(|_| 1 + (self.rng.next_u64() % 3) as u8).collect();
2440 let ids: Vec<String> = (0..n).map(|_| format!("L{}", self.rng.next_u64() % lessons as u64)).collect();
2441 self.check(session, stop, &grades, &ids, truth, route);
2442 }
2443 self.ms += 1;
2444 let asked: Vec<String> =
2445 self.lines.iter().filter(|o| o.session == session && o.kind == outcome::DELIVERY).flat_map(|o| o.lessons.clone()).collect();
2446 if let Some(l) = asked.first() {
2447 self.lines.push(Outcome {
2448 kind: outcome::NOT_CORRECTED.into(),
2449 session: session.into(),
2450 lessons: vec![l.clone()],
2451 ms: self.ms,
2452 reason: outcome::UNJUDGED_NONE.into(),
2453 ..Default::default()
2454 });
2455 }
2456 }
2457
2458 fn check(&mut self, session: &str, stop: bool, grades: &[u8], ids: &[String], truth: &Truth, route: &mut Router) {
2459 self.ms += 1;
2460 let check = crate::select::check_id(session, if stop { "stop" } else { "live" }, self.ms, 1);
2461 let (routes, p, pf, policy) = route(grades, stop, &mut self.rng);
2462 let pick = |r: Route| ids.iter().zip(&routes).filter(|(_, x)| **x == r).map(|(l, _)| l.clone()).collect::<Vec<_>>();
2463 self.lines.push(Outcome {
2464 kind: outcome::GRADED.into(),
2465 session: session.into(),
2466 lessons: ids.to_vec(),
2467 ms: self.ms,
2468 channel: if stop { "stop" } else { "live" }.into(),
2469 grades: grades.to_vec(),
2470 ranks: (0..ids.len() as u16).collect(),
2471 applied: ids.len() as u16,
2472 chosen: pick(Route::Ask),
2473 free: pick(Route::Free),
2474 placebo: pick(Route::Placebo),
2475 psi: 1.0,
2476 p,
2477 pf,
2478 check: check.clone(),
2479 policy: policy.into(),
2480 ..Default::default()
2481 });
2482 let three = grades.contains(&3);
2483 let m = |r: Route| routes.iter().filter(|x| **x == r).count();
2484 for (j, (l, r)) in ids.iter().zip(&routes).enumerate() {
2485 if *r == Route::Not {
2486 continue;
2487 }
2488 self.ms += 1;
2489 let delivered = match r {
2490 Route::Ask => outcome::ROUTE_ASKED,
2491 Route::Free => outcome::ROUTE_FREE,
2492 _ => outcome::ROUTE_PLACEBO,
2493 };
2494 self.lines.push(Outcome {
2495 kind: outcome::DELIVERY.into(),
2496 session: session.into(),
2497 lessons: vec![l.clone()],
2498 ms: self.ms,
2499 check: check.clone(),
2500 route: delivered.into(),
2501 ..Default::default()
2502 });
2503 let f = Feat { grade: grades[j], rank: j, stop, three, pool: 0.0 };
2504 let as_route = if *r == Route::Placebo { Route::Not } else { *r };
2505 let y = self.rng.unit() < truth.p(&f, as_route, m(*r).max(1));
2506 let channel = match r {
2507 Route::Ask => {
2508 if stop {
2509 "stop"
2510 } else {
2511 "live"
2512 }
2513 }
2514 Route::Free => "queued",
2515 _ => outcome::PLACEBO,
2516 };
2517 self.lines.push(Outcome {
2518 kind: if y { outcome::CHANGED } else { outcome::IGNORED }.into(),
2519 session: session.into(),
2520 lessons: vec![l.clone()],
2521 ms: self.ms,
2522 channel: channel.into(),
2523 check: check.clone(),
2524 judge: 2,
2525 ..Default::default()
2526 });
2527 }
2528 }
2529
2530 fn text(&self) -> String {
2531 self.lines.iter().map(|o| format!("{}\n", serde_json::to_string(o).unwrap())).collect()
2532 }
2533
2534 fn summary(&self) -> Summary {
2535 summary::fold(&self.text(), Summary::default())
2536 }
2537 }
2538
2539 fn uniform(grades: &[u8], stop: bool, rng: &mut Rng) -> (Vec<Route>, Vec<f64>, Vec<f64>, &'static str) {
2541 let routes: Vec<Route> = grades
2542 .iter()
2543 .map(|g| if stop && *g == 3 { Route::Ask } else { [Route::Ask, Route::Free, Route::Placebo][(rng.next_u64() % 3) as usize] })
2544 .collect();
2545 let p = grades.iter().map(|g| if stop && *g == 3 { 1.0 } else { 0.3333 }).collect();
2546 let pf = grades.iter().map(|g| if stop && *g == 3 { 0.0 } else { 0.3333 }).collect();
2547 (routes, p, pf, "fixed")
2548 }
2549
2550 fn fixed_rule(grades: &[u8], _: bool, _: &mut Rng) -> (Vec<Route>, Vec<f64>, Vec<f64>, &'static str) {
2552 let ids: Vec<String> = (0..grades.len()).map(|i| format!("x{i}")).collect();
2553 let units: Vec<Unit> = ids.iter().zip(grades).enumerate().map(|(r, (l, g))| unit(l, *g, r)).collect();
2554 let routes = fixed_routes(&units, &[]);
2555 let p = routes.iter().map(|r| f64::from(u8::from(*r == Route::Ask))).collect();
2556 let pf = routes.iter().map(|r| f64::from(u8::from(*r == Route::Free))).collect();
2557 (routes, p, pf, "fixed")
2558 }
2559
2560 fn truth() -> Truth {
2561 let mut b = [0.0; P];
2562 b[B0] = -1.0;
2563 b[G3] = 1.2;
2564 b[ASK] = 0.6;
2565 b[NOT] = -0.8;
2566 Truth { b, lift: 0.0 }
2567 }
2568
2569 fn planted(sessions: usize, checks: usize, seed: u64) -> Summary {
2570 let mut sim = Sim::new(seed);
2571 for i in 0..sessions {
2572 sim.session(&format!("s{i}"), checks, 30, &truth(), &mut uniform);
2573 }
2574 sim.summary()
2575 }
2576
2577 #[test]
2578 fn the_fit_recovers_a_planted_world() {
2579 let s = planted(24, 40, 1);
2580 let fit = fit(&s, None);
2581 let m = fit.model.as_ref().expect("a model");
2582 assert!(m.converged, "{m:?}");
2583 assert!(fit.labels > 600, "{}", fit.labels);
2584 let cov = |k: usize| (0..=k).map(|j| m.chol[k * P + j].powi(2)).sum::<f64>().sqrt();
2585 for k in [B0, G3, ASK, NOT] {
2586 let (got, want, sd) = (m.beta[k], truth().b[k], cov(k));
2587 assert!((got - want).abs() < 3.0 * sd + 0.15, "{}: {got:.3} vs {want} (sd {sd:.3})", NAMES[k]);
2588 }
2589 assert!(m.beta[ASK] > 0.0 && m.beta[NOT] < 0.0, "{:?}", m.beta);
2590 assert!(m.sd[RANK] < ARD_START, "{}", m.sd[RANK]);
2592 assert!(m.lessons.len() <= 30 && m.sessions.len() == 24);
2593 assert!(m.beta.iter().chain(&m.chol).all(|x| x.is_finite()));
2594 }
2595
2596 #[test]
2600 fn a_dated_prior_keeps_its_sd_where_the_labels_say_nothing() {
2601 let mut sim = Sim::new(12);
2602 for i in 0..16 {
2603 sim.session(&format!("s{i}"), 16, 12, &truth(), &mut fixed_rule);
2604 }
2605 let f = fit(&sim.summary(), None);
2606 let m = f.model.as_ref().expect("a model");
2607 for k in [ASK_M, FREE_M] {
2608 assert_eq!(m.sd[k], DILUTION_PRIOR.1, "{}: {:?}", NAMES[k], m.sd);
2609 }
2610 assert_eq!((m.sd[ASK], m.sd[NOT]), (ASK_PRIOR.1, NOT_PRIOR.1));
2611 assert!([RANK, ASK_STOP, ASK_G3, NOT_G, POOL].iter().any(|&k| m.sd[k] != ARD_START), "EM ran on the rest: {:?}", m.sd);
2612 }
2613
2614 #[test]
2618 fn the_pruned_prior_is_the_pruned_models_before_any_label() {
2619 let (full, pruned) = (prior(), prior_pruned());
2620 let (fm, pm) = (full.model.as_ref().expect("a prior"), pruned.model.as_ref().expect("a pruned prior"));
2621 let spec = coefs();
2622 for (k, name) in NAMES.iter().enumerate() {
2623 let want = if PRUNED_KEEPS.contains(&k) { (spec[k].mean, spec[k].sd) } else { (0.0, SD_FLOOR) };
2624 assert!((pm.beta[k] - want.0).abs() < 1e-9 && pm.sd[k] == want.1, "{name}: {} sd {}", pm.beta[k], pm.sd[k]);
2625 }
2626 assert_eq!((pm.spread, fm.spread), ([0.0; FAMILIES], [LEVEL_START; FAMILIES]));
2627 let f = Feat { grade: 2, rank: 3, stop: true, three: false, pool: 0.5 };
2628 let at = |fit: &Fit| predict(fit, &f, Route::Not, 1, "unseen", "L99").expect("a prediction");
2630 assert_ne!(at(&pruned), at(&full), "the full prior's rank, pool and level variance are not the pruned model's");
2631 assert_eq!(pruned.rates, full.rates);
2632 }
2633
2634 #[test]
2639 fn the_pruned_model_keeps_grade_route_and_dilution_alone() {
2640 let mut t = truth();
2641 t.b[RANK] = -0.6;
2642 t.b[STOP] = 0.8;
2643 let mut sim = Sim::new(4);
2644 for i in 0..20 {
2645 sim.session(&format!("s{i}"), 30, 20, &t, &mut uniform);
2646 }
2647 let s = sim.summary();
2648 let (full, pruned) = (fit(&s, None), fit_pruned(&s, None));
2649 let (fm, pm) = (full.model.as_ref().expect("a full model"), pruned.model.as_ref().expect("a pruned model"));
2650 assert!(fm.beta[RANK] < -0.2 && fm.beta[STOP] > 0.2 && !fm.sessions.is_empty(), "the scenario: {:?}", fm.beta);
2651 assert!(pm.current() && pm.converged && pruned.labels == full.labels && pruned.key == full.key);
2652 for (k, name) in NAMES.iter().enumerate() {
2653 if PRUNED_KEEPS.contains(&k) {
2654 assert!(pm.sd[k] > SD_FLOOR, "{name} is kept");
2655 } else {
2656 assert_eq!(pm.sd[k], SD_FLOOR, "{name}");
2657 assert!(pm.beta[k].abs() < 3.0 * SD_FLOOR, "{name}: {}", pm.beta[k]);
2658 }
2659 }
2660 assert!(pm.beta[ASK] > 0.0 && pm.beta[NOT] < 0.0, "{:?}", pm.beta);
2661 assert!(pm.sessions.is_empty() && pm.lessons.is_empty() && pm.judges.is_empty() && pm.judge.is_empty());
2662 assert_eq!(pm.spread, [0.0; FAMILIES]);
2663 assert!(HALF_LIVES.contains(&pruned.half_life));
2664 let f = Feat { grade: 2, rank: 3, stop: true, three: false, pool: 0.0 };
2665 let at = |session: &str, lesson: &str| predict(&pruned, &f, Route::Free, 2, session, lesson);
2666 assert_eq!(at("s0", "L1"), at("unseen", "L99"), "no level effect");
2667 assert_ne!(predict(&full, &f, Route::Free, 2, "s0", "L1"), predict(&full, &f, Route::Free, 2, "unseen", "L99"), "the full model's levels");
2668 }
2669
2670 #[test]
2674 fn eliminating_a_family_equals_solving_it_densely() {
2675 let mut rng = Rng::new(9);
2676 let rows: Vec<([f64; 3], usize, f64)> = (0..400)
2677 .map(|i| {
2678 let x = [1.0, rng.unit() * 2.0 - 1.0, f64::from(u8::from(rng.unit() < 0.4))];
2679 let l = i % 4;
2680 let eta = -0.3 + 0.8 * x[1] - 0.5 * x[2] + [0.6, -0.4, 0.1, 0.0][l];
2681 (x, l, f64::from(u8::from(rng.unit() < sigmoid(eta))))
2682 })
2683 .collect();
2684 let spread = 0.7;
2685 let spec3 = [Coef { mean: 0.0, sd: 2.5, ard: false, nonpos: false }; 3];
2686 let obs: Vec<Obs<3, 1>> = rows.iter().map(|(x, l, y)| Obs { x: *x, y: *y, w: 1.0 + f64::from(*l as u8) * 0.1, lv: [Some(*l)] }).collect();
2687 let a = map(&obs, [4], &spec3, &[2.5; 3], &[spread], None).unwrap();
2688 let mut spec7 = [Coef { mean: 0.0, sd: spread, ard: false, nonpos: false }; 7];
2689 spec7[..3].copy_from_slice(&spec3);
2690 let sd7: [f64; 7] = std::array::from_fn(|k| spec7[k].sd);
2691 let dense: Vec<Obs<7, 0>> = rows
2692 .iter()
2693 .map(|(x, l, y)| {
2694 let mut z = [0.0; 7];
2695 z[..3].copy_from_slice(x);
2696 z[3 + l] = 1.0;
2697 Obs { x: z, y: *y, w: 1.0 + f64::from(*l as u8) * 0.1, lv: [] }
2698 })
2699 .collect();
2700 let b = map(&dense, [], &spec7, &sd7, &[], None).unwrap();
2701 assert!(a.converged && b.converged);
2702 for k in 0..3 {
2703 assert!((a.beta[k] - b.beta[k]).abs() < 1e-6, "{k}: {} {}", a.beta[k], b.beta[k]);
2704 for j in 0..3 {
2705 assert!((a.cov[k][j] - b.cov[k][j]).abs() < 1e-6, "cov {k} {j}");
2706 }
2707 }
2708 for l in 0..4 {
2709 assert!((a.u[0][l] - b.beta[3 + l]).abs() < 1e-6, "level {l}");
2710 assert!((a.var_u[0][l] - b.cov[3 + l][3 + l]).abs() < 1e-6, "var {l}: {} {}", a.var_u[0][l], b.cov[3 + l][3 + l]);
2711 }
2712 }
2713
2714 #[test]
2715 fn a_bounded_slope_stops_at_zero_and_a_negative_one_is_learned() {
2716 let mut rng = Rng::new(3);
2717 let obs: Vec<Obs<3, 0>> = (0..600)
2718 .map(|_| {
2719 let x = [1.0, rng.unit() * 3.0, rng.unit() * 3.0];
2720 let eta = -0.5 + 0.9 * x[1] - 0.7 * x[2];
2721 Obs { x, y: f64::from(u8::from(rng.unit() < sigmoid(eta))), w: 1.0, lv: [] }
2722 })
2723 .collect();
2724 let bounded = Coef { mean: 0.0, sd: 2.5, ard: false, nonpos: true };
2725 let spec = [Coef { nonpos: false, ..bounded }, bounded, bounded];
2726 let e = map(&obs, [], &spec, &[2.5; 3], &[], None).unwrap();
2727 assert_eq!(e.beta[1], 0.0, "a positive effect under a ≤ 0 bound sits at the bound");
2728 assert!(e.beta[2] < -0.3, "{:?}", e.beta);
2729 }
2730
2731 fn brute(v: &Valuer, nofree: &[bool], rail: usize, cap: usize) -> f64 {
2733 let n = v.n();
2734 let mut best = f64::NEG_INFINITY;
2735 for code in 0..3usize.pow(n as u32) {
2736 let mut x = code;
2737 let routes: Vec<Route> = (0..n)
2738 .map(|_| {
2739 let r = [Route::Ask, Route::Free, Route::Not][x % 3];
2740 x /= 3;
2741 r
2742 })
2743 .collect();
2744 let (m_q, m_f) = (routes.iter().filter(|r| **r == Route::Ask).count(), routes.iter().filter(|r| **r == Route::Free).count());
2745 let forced_ok = v.feats.iter().zip(&routes).all(|(f, r)| !forced(f) || *r == Route::Ask);
2746 let free_ok = nofree.iter().zip(&routes).all(|(x, r)| !x || *r != Route::Free);
2747 let f = v.feats.iter().filter(|f| forced(f)).count();
2748 if forced_ok && free_ok && m_q <= rail.max(f) && m_f <= cap {
2749 best = best.max(v.value(&routes));
2750 }
2751 }
2752 best
2753 }
2754
2755 #[test]
2756 fn the_solver_equals_brute_force() {
2757 let mut rng = Rng::new(11);
2758 for case in 0..400 {
2759 let n = case % 8;
2760 let stop = case % 3 == 0;
2761 let feats: Vec<Feat> = (0..n).map(|r| Feat { grade: 1 + (rng.next_u64() % 3) as u8, rank: r, stop, three: false, pool: 0.0 }).collect();
2762 let mut b = [0.0; P];
2763 for x in &mut b {
2764 *x = 2.0 * normal(&mut rng);
2765 }
2766 b[ASK_M] = -rng.unit() * 2.0;
2767 b[FREE_M] = -rng.unit() * 2.0;
2768 let t = Theta { b, base: (0..n).map(|_| normal(&mut rng)).collect() };
2769 let rates = Rates { free_stop: rng.unit(), free_live: rng.unit(), later: rng.unit(), wait: 0.0, ask_live: 1.0 };
2770 let v = Valuer { t: &t, feats: &feats, rates: &rates };
2771 let nofree: Vec<bool> = (0..n).map(|_| case % 2 == 1 && rng.unit() < 0.4).collect();
2773 let table = Table::of(&v, &nofree);
2774 for (rail, cap) in [(20, 10), (2, 1), (1, 3), (0, 0)] {
2775 let got = solve(&table, rail, cap);
2776 let want = brute(&v, &nofree, rail, cap);
2777 assert!((v.value(&got) - want).abs() < 1e-12, "case {case} rail {rail} cap {cap}: {} vs {want} {got:?}", v.value(&got));
2778 assert!((table.value(&got) - v.value(&got)).abs() < 1e-12, "the table values as the model does");
2779 assert!(feats.iter().zip(&got).all(|(f, r)| !forced(f) || *r == Route::Ask));
2780 assert!(nofree.iter().zip(&got).all(|(x, r)| !x || *r != Route::Free), "case {case}: {nofree:?} {got:?}");
2781 }
2782 }
2783 }
2784
2785 #[test]
2786 fn the_value_of_a_route_is_the_models_lift() {
2787 let f = Feat { grade: 3, rank: 2, stop: false, three: true, pool: 0.3 };
2788 let mut b = [0.0; P];
2789 for (i, x) in b.iter_mut().enumerate() {
2790 *x = 0.1 * i as f64 - 0.6;
2791 }
2792 let base = dot(&b, &design(&f, Route::Free, 1)) + 0.25;
2793 let t = Theta { b, base: vec![base] };
2794 let rates = Rates { free_stop: 0.5, free_live: 0.8, later: 0.4, wait: 0.0, ask_live: 0.6 };
2795 let v = Valuer { t: &t, feats: std::slice::from_ref(&f), rates: &rates };
2796 let p = |r: Route, m: usize| sigmoid(dot(&b, &design(&f, r, m)) + 0.25);
2797 assert!((v.ask(0, 3) - 0.6 * (p(Route::Ask, 3) - p(Route::Not, 1))).abs() < 1e-12);
2799 assert!((v.free(0, 2) - 0.8 * (p(Route::Free, 2) - p(Route::Not, 1))).abs() < 1e-12);
2800 for r in [Route::Not, Route::Placebo] {
2803 assert_eq!(v.value(&[r]), 0.0, "during the turn a lesson left for later counts nothing here: {r:?}");
2804 }
2805 let stop = Feat { stop: true, ..f };
2806 let t = Theta { b, base: vec![dot(&b, &design(&stop, Route::Free, 1)) + 0.25] };
2807 let v = Valuer { t: &t, feats: std::slice::from_ref(&stop), rates: &rates };
2808 let p = |r: Route, m: usize| sigmoid(dot(&b, &design(&stop, r, m)) + 0.25);
2809 assert!((v.ask(0, 3) - (p(Route::Ask, 3) - p(Route::Not, 1))).abs() < 1e-12, "a stop's question blocks: it arrives");
2810 assert!((v.free(0, 2) - 0.5 * (p(Route::Free, 2) - p(Route::Not, 1))).abs() < 1e-12);
2811 for r in [Route::Not, Route::Placebo] {
2812 assert_eq!(v.value(&[r]), 0.0, "at the stop a lesson not delivered counts nothing: {r:?}");
2813 }
2814 }
2815
2816 #[test]
2817 fn a_live_question_without_a_3_arrives_as_a_hand_over_does() {
2818 let mut b = [0.0; P];
2819 for (i, x) in b.iter_mut().enumerate() {
2820 *x = 0.1 * i as f64 - 0.6;
2821 }
2822 let t = Theta { b, base: vec![0.25] };
2823 let rates = Rates { free_stop: 0.5, free_live: 0.3, later: 0.4, wait: 0.0, ask_live: 0.9 };
2824 let ask = |three: bool| {
2825 let f = Feat { grade: 2, rank: 0, stop: false, three, pool: 0.0 };
2826 Valuer { t: &t, feats: std::slice::from_ref(&f), rates: &rates }.ask(0, 1)
2827 };
2828 let lift = sigmoid(0.25 + b[ASK]) - sigmoid(0.25 + b[NOT]);
2830 assert!(lift.abs() > 0.01);
2831 assert!((ask(true) - 0.9 * lift).abs() < 1e-12);
2833 assert!((ask(false) - 0.3 * lift).abs() < 1e-12);
2836 }
2837
2838 fn open_gate() -> Gate {
2840 Gate { gain: vec![0.0; DRAWS], allowance: 5.0, ..Gate::default() }
2841 }
2842
2843 #[test]
2844 fn cold_start_is_the_fixed_rule() {
2845 let fit_model = fit(&planted(6, 20, 2), None);
2846 assert!(fit_model.model.is_some());
2847 let wrong_len = Gate { gain: vec![0.0; 3], allowance: 1.0, ..Gate::default() };
2848 let ids: Vec<String> = (0..14).map(|i| format!("L{i}")).collect();
2849 let mut grades = tables(5);
2850 grades.extend([vec![3; 12], [vec![1; 11], vec![2; 3]].concat()]);
2851 for g in &grades {
2852 let units: Vec<Unit> = ids.iter().zip(g).enumerate().map(|(r, (l, g))| unit(l, *g, r)).collect();
2853 let want = fixed_routes(&units, &[]);
2854 for (fit, gate) in [(&Fit::default(), &open_gate()), (&fit_model, &Gate::default()), (&fit_model, &wrong_len)] {
2855 for stop in [true, false] {
2856 let d = choose(&units, &Check { session: "s", stop, tokens: 0 }, fit, gate, ALPHA, 7, &[]);
2857 assert_eq!(d.routes, want, "{g:?}");
2858 assert_eq!(d.p, want.iter().map(|r| f64::from(u8::from(*r == Route::Ask))).collect::<Vec<_>>());
2859 assert_eq!(d.pf, want.iter().map(|r| f64::from(u8::from(*r == Route::Free))).collect::<Vec<_>>());
2860 assert_eq!((d.source, d.gate), ("fixed", 0.0));
2861 }
2862 }
2863 }
2864 }
2865
2866 #[test]
2867 fn n_zero_and_n_one() {
2868 let empty = fit(&Summary::default(), None);
2869 assert!(empty.model.is_none() && empty.labels == 0);
2870 let mut sim = Sim::new(5);
2871 let mut one = |_: &[u8], _: bool, _: &mut Rng| (vec![Route::Ask], vec![1.0], vec![0.0], "fixed");
2872 sim.check("s", false, &[2], &["A".to_string()], &truth(), &mut one);
2873 let f = fit(&sim.summary(), None);
2874 let m = f.model.as_ref().expect("one label is a model");
2875 assert_eq!(f.labels, 1);
2876 assert!(m.beta.iter().chain(&m.chol).all(|x| x.is_finite()), "{m:?}");
2877 let gate = open_gate();
2878 assert!(choose(&[], &Check { session: "s", stop: true, tokens: 0 }, &f, &gate, ALPHA, 1, &[]).routes.is_empty());
2879 for g in 1..=3 {
2880 let d = choose(&[unit("A", g, 0)], &Check { session: "s", stop: true, tokens: 0 }, &f, &gate, ALPHA, 1, &[]);
2881 assert_eq!(d.routes.len(), 1);
2882 assert!((d.p[0] + d.pf[0]) <= 1.0 + 1e-9);
2883 }
2884 }
2885
2886 #[test]
2889 fn decisions_are_deterministic_and_only_the_test_varies() {
2890 let f = fit(&planted(8, 20, 3), None);
2891 let gate = open_gate();
2892 let units = [unit("L1", 2, 0), unit("L2", 1, 1), unit("L3", 3, 2)];
2893 let check = Check { session: "new", stop: false, tokens: 0 };
2894 let a = choose(&units, &check, &f, &gate, ALPHA, 42, &[]);
2895 assert_eq!(a, choose(&units, &check, &f, &gate, ALPHA, 42, &[]));
2896 let plays: std::collections::HashSet<Vec<Route>> = (0..60).map(|s| choose(&units, &check, &f, &gate, ALPHA, s, &[]).routes).collect();
2897 assert!(plays.len() > 1, "the test's count varies with the check: {plays:?}");
2898 let stop = Check { stop: true, ..check };
2899 let at_stop: std::collections::HashSet<Vec<Route>> = (0..60).map(|s| choose(&units, &stop, &f, &gate, ALPHA, s, &[]).routes).collect();
2900 assert_eq!(at_stop.len(), 1, "no Thompson play: {at_stop:?}");
2901 }
2902
2903 #[test]
2904 fn propensities_match_the_frequency_of_the_play() {
2905 let f = fit(&planted(6, 15, 4), None);
2906 let gate = open_gate();
2907 let units = [unit("L1", 2, 0), unit("L2", 1, 1), unit("L3", 1, 2)];
2908 let check = Check { session: "new", stop: false, tokens: 0 };
2909 let n = 300;
2910 let (mut asked, mut free) = ([0.0; 3], [0.0; 3]);
2911 let (mut p, mut pf) = ([0.0; 3], [0.0; 3]);
2912 for seed in 0..n {
2913 let d = choose(&units, &check, &f, &gate, ALPHA, seed, &[]);
2914 for j in 0..3 {
2915 asked[j] += f64::from(u8::from(d.routes[j] == Route::Ask)) / n as f64;
2916 free[j] += f64::from(u8::from(d.routes[j] == Route::Free)) / n as f64;
2917 p[j] += d.p[j] / n as f64;
2918 pf[j] += d.pf[j] / n as f64;
2919 }
2920 }
2921 for j in 0..3 {
2922 for (freq, prop) in [(asked[j], p[j]), (free[j], pf[j])] {
2923 let se = (prop * (1.0 - prop) / n as f64).sqrt();
2924 assert!((freq - prop).abs() <= 4.0 * se + 0.01, "unit {j}: played {freq:.3}, propensity {prop:.3}");
2925 }
2926 }
2927 assert!(asked.iter().chain(&free).any(|x| *x > 0.05 && *x < 0.95), "the play varies: {asked:?} {free:?}");
2928 }
2929
2930 fn free_beats_ask() -> Fit {
2932 let mut sim = Sim::new(8);
2933 let mut t = truth();
2934 t.b[ASK] = -3.0;
2935 for i in 0..10 {
2936 sim.session(&format!("s{i}"), 12, 10, &t, &mut uniform);
2937 }
2938 fit(&sim.summary(), None)
2939 }
2940
2941 #[test]
2944 fn a_stop_grade_three_is_never_routed_away_from_asking() {
2945 let f = free_beats_ask();
2946 let gate = open_gate();
2947 let units = [unit("L1", 3, 0), unit("L2", 1, 1)];
2948 let live = choose(&units, &Check { session: "n", stop: false, tokens: 0 }, &f, &gate, ALPHA, 1, &[]);
2949 assert_eq!(live.routes[0], Route::Free, "during the turn the model hands it over: {live:?}");
2950 for seed in 0..20 {
2951 let d = choose(&units, &Check { session: "n", stop: true, tokens: 0 }, &f, &gate, ALPHA, seed, &[]);
2952 assert_eq!((d.routes[0], d.p[0], d.pf[0]), (Route::Ask, 1.0, 0.0), "{d:?}");
2953 }
2954 }
2955
2956 #[test]
2960 fn a_lesson_the_session_had_never_goes_free() {
2961 let f = free_beats_ask();
2962 let gate = open_gate();
2963 let units = [unit("L1", 2, 0), unit("L2", 1, 1), unit("L3", 1, 2)];
2964 let had = ["L2".to_string()];
2965 let check = Check { session: "n", stop: false, tokens: 0 };
2966 assert_eq!(fixed_routes(&units, &had), [Route::Free, Route::Not, Route::Free]);
2967 let cold = choose(&units, &check, &Fit::default(), &gate, ALPHA, 1, &had);
2968 assert_eq!((cold.routes, cold.pf[1], cold.source), (fixed_routes(&units, &had), 0.0, "fixed"));
2969 let mut freed = 0;
2970 for seed in 0..20 {
2971 freed += usize::from(choose(&units, &check, &f, &gate, ALPHA, seed, &[]).routes[1] == Route::Free);
2972 let d = choose(&units, &check, &f, &gate, ALPHA, seed, &had);
2973 assert!(d.routes[1] != Route::Free && d.pf[1] == 0.0, "{d:?}");
2974 }
2975 assert!(freed > 0, "the scenario: without `had` the model hands L2 over");
2976 let block = [unit("L2", 3, 0), unit("L1", 1, 1)];
2978 let d = choose(&block, &Check { stop: true, ..check }, &f, &gate, ALPHA, 3, &had);
2979 assert_eq!((d.routes[0], d.p[0]), (Route::Ask, 1.0));
2980 }
2981
2982 #[test]
2985 fn a_check_past_the_solvers_size_takes_the_fixed_rule() {
2986 let f = free_beats_ask();
2987 let gate = open_gate();
2988 let ids: Vec<String> = (0..=SOLVE_MAX).map(|i| format!("L{i}")).collect();
2989 let units: Vec<Unit> = ids.iter().enumerate().map(|(r, l)| unit(l, if r == 0 { 3 } else { 2 }, r)).collect();
2991 let check = Check { session: "n", stop: false, tokens: 0 };
2992 let at = choose(&units[..SOLVE_MAX], &check, &f, &gate, ALPHA, 1, &[]);
2993 assert_eq!(at.source, EVIDENCE, "{at:?}");
2994 assert_ne!(at.routes, fixed_routes(&units[..SOLVE_MAX], &[]), "at the size the model hands them over: {at:?}");
2995 let past = choose(&units, &check, &f, &gate, ALPHA, 1, &[]);
2996 let base = fixed_routes(&units, &[]);
2997 assert_eq!((&past.routes, past.source, past.risk), (&base, "size", 0.0));
2998 assert_eq!(past.p, base.iter().map(|r| f64::from(u8::from(*r == Route::Ask))).collect::<Vec<_>>(), "its choice is its propensity");
2999 }
3000
3001 #[test]
3004 fn a_confounded_history_keeps_the_stop_three_asked() {
3005 let mut sim = Sim::new(12);
3006 for i in 0..12 {
3007 sim.session(&format!("s{i}"), 16, 12, &truth(), &mut fixed_rule);
3008 }
3009 let s = sim.summary();
3010 assert!(s.applies.iter().all(|a| a.grade != 3 || a.route == Route::Ask || a.route == Route::Not), "never free");
3011 let f = fit(&s, None);
3012 let gate = open_gate();
3013 for seed in 0..10 {
3014 let d = choose(&[unit("L1", 3, 0), unit("L2", 2, 1)], &Check { session: "n", stop: true, tokens: 0 }, &f, &gate, ALPHA, seed, &[]);
3015 assert_eq!((d.routes[0], d.p[0]), (Route::Ask, 1.0));
3016 }
3017 }
3018
3019 fn three_stop_world(lift: f64) -> Summary {
3022 let mut t = truth();
3023 t.b[ASK] = 0.0;
3024 t.b[NOT] = 0.0;
3025 t.lift = lift;
3026 let mut cold = |grades: &[u8], stop: bool, rng: &mut Rng| {
3027 let (routes, p, pf, _) = fixed_rule(grades, stop, rng);
3028 (routes, p, pf, "learned")
3029 };
3030 let mut sim = Sim::new(1);
3031 for i in 0..40 {
3032 sim.session(&format!("s{i}"), 20, 12, &t, &mut cold);
3033 }
3034 summary::with_fit(sim.summary(), None)
3035 }
3036
3037 #[test]
3042 fn a_lift_of_the_stops_with_a_three_is_not_taken_for_asking() {
3043 let (lifted, plain) = (three_stop_world(1.5), three_stop_world(0.0));
3044 let units = [unit("A", 2, 0), unit("B", 1, 1)];
3045 assert_eq!(fixed_routes(&units, &[]), [Route::Free; 2], "the fixed rule hands both over");
3046 let asked = |s: &Summary| -> f64 {
3047 assert!(remaining(&s.gate) > 0.0, "the gate opened on the history: {:?}", s.gate.allowance);
3048 let seeds = 20u32;
3049 (0..seeds)
3050 .map(|seed| {
3051 let d = choose(&units, &Check { session: "new", stop: true, tokens: 0 }, &s.fit, &s.gate, ALPHA, u64::from(seed), &[]);
3052 assert_ne!(d.source, "fixed", "{d:?}");
3053 d.p[0]
3054 })
3055 .sum::<f64>()
3056 / f64::from(seeds)
3057 };
3058 let (with_lift, without) = (asked(&lifted), asked(&plain));
3059 let m = lifted.fit.model.as_ref().expect("a model");
3060 let sd = |k: usize| (0..=k).map(|j| m.chol[k * P + j].powi(2)).sum::<f64>().sqrt();
3061 let (ask_stop, twin) = ((m.beta[ASK_STOP], sd(ASK_STOP)), (m.beta[THREE_STOP], sd(THREE_STOP)));
3062 let seen = format!("asked {with_lift:.3} with the lift, {without:.3} without; ask_stop {ask_stop:.3?}, three_stop {twin:.3?}");
3063 assert!(with_lift < 0.9, "a stop without a 3 is not blocked by the lift: {seen}");
3064 assert!((with_lift - without).abs() < 0.15, "{seen}");
3065 assert!(ask_stop.0.abs() < 2.0 * ask_stop.1, "asking at the stop is not identified: {seen}");
3066 assert!(twin.0 > 0.75, "the lift is the twin's: {seen}");
3067 }
3068
3069 #[test]
3074 fn the_gate_never_risks_more_than_its_alpha_share() {
3075 let spec = coefs();
3078 let mut beta: Vec<f64> = spec.iter().map(|c| c.mean).collect();
3079 beta[ASK] = -0.4;
3080 beta[B0] = -0.5;
3081 let mut chol = vec![0.0; P * P];
3082 for k in 0..P {
3083 chol[k * P + k] = if k == ASK { 1.0 } else { 0.3 };
3084 }
3085 let model = Model { beta, chol, sd: spec.iter().map(|c| c.sd).collect(), spread: [0.3; FAMILIES], ..Model::default() };
3086 let rates = Rates { free_stop: 1.0, free_live: 1.0, later: 0.5, wait: 0.0, ask_live: 1.0 };
3087 let f = Fit { key: "test".into(), model: Some(model), rates, ..Fit::default() };
3088 let ids: Vec<String> = (0..10).map(|i| format!("L{i}")).collect();
3089 let mut refused = 0;
3090 let mut run = |gated: bool, rng: &mut Rng| -> (bool, usize) {
3091 let mut gate = Gate::default();
3092 let mut broke = false;
3093 let mut deviated = 0;
3094 for c in 0..200 {
3095 let n = 1 + (rng.next_u64() % 3) as usize;
3096 let units: Vec<Unit> = (0..n).map(|r| unit(&ids[(rng.next_u64() % 10) as usize], 1 + (rng.next_u64() % 3) as u8, r)).collect();
3097 let session = format!("t{}", c / 10);
3098 let check = Check { session: &session, stop: c % 5 == 0, tokens: 0 };
3099 let routes = if c < 10 {
3100 fixed_routes(&units, &[])
3101 } else if gated {
3102 let d = choose(&units, &check, &f, &gate, ALPHA, c as u64, &[]);
3103 match d.source {
3106 GATE => {
3107 assert_eq!((&d.routes, d.risk), (&fixed_routes(&units, &[]), 0.0), "{d:?}");
3108 refused += 1;
3109 }
3110 _ if d.routes != fixed_routes(&units, &[]) => {
3111 assert_eq!(d.source, EXPERIMENT, "{d:?}");
3112 assert!(d.risk <= d.gate + 1e-12, "{d:?}");
3113 }
3114 _ => {}
3115 }
3116 d.routes
3117 } else {
3118 let open = Gate { allowance: f64::INFINITY, ..gate.clone() };
3119 choose(&units, &check, &f, &open, ALPHA, c as u64, &[]).routes
3120 };
3121 deviated += usize::from(routes != fixed_routes(&units, &[]));
3122 ledger_add_without(&f, &mut gate, &check, &units, &routes, &[]);
3123 broke |= lower_quantile(&mut gate.gain.clone()) < -gate.allowance - 1e-9;
3124 }
3125 (broke, deviated)
3126 };
3127 let (broke, deviated) = run(true, &mut Rng::new(77));
3128 assert!(!broke && deviated > 0, "gated: broke {broke}, deviated {deviated}");
3129 let (broke, _) = run(false, &mut Rng::new(77));
3130 assert!(broke, "ungated, the same run overspends: the scenario needs the gate");
3131 assert!(refused > 0, "the gate refused deviations, logged as `gate`");
3132 }
3133
3134 #[test]
3135 fn no_price_without_a_budget_and_spend_within_one() {
3136 let mut s = planted(10, 12, 6);
3137 s.pool.bins = (0..6).map(|b| (400 * (1u64 << b), 200 / (b as u64 + 1))).collect();
3139 s.pool.shown = 40 * 120;
3140 s.pool.tokens = 12_000 * 120;
3141 let free = fit(&s, None);
3142 assert_eq!((free.lambda, free.budget), (0.0, None), "no budget, no price");
3143 let checks = s.sessions.iter().map(|x| f64::from(x.checks)).sum::<f64>() / s.sessions.len() as f64;
3144 let spend = |f: &Fit| {
3145 s.sessions.iter().map(|x| f64::from(x.checks) * pool_tokens(f, true, None, session_seed(&x.id)) as f64).sum::<f64>() / s.sessions.len() as f64
3146 };
3147 assert!(spend(&free) >= checks * crate::whole::TOKENS as f64 - 1e-6, "unpriced, the pool keeps the baseline");
3148 let budget = checks * 9_000.0;
3149 let priced = fit(&s, Some(budget));
3150 assert!(priced.lambda > 0.0, "a budget below today's spend prices tokens");
3151 assert!(spend(&priced) <= budget + 1e-6, "{} > {budget}", spend(&priced));
3152 let roomy = fit(&s, Some(checks * 1e6));
3153 assert_eq!(roomy.lambda, 0.0, "a budget the baseline meets costs nothing");
3154 }
3155
3156 #[test]
3160 fn a_budget_holds_where_pools_may_grow() {
3161 let mut s = Summary::default();
3162 for i in 0..10 {
3163 s.sessions.push(summary::Session { id: format!("s{i}"), checks: 10, ..summary::Session::default() });
3164 }
3165 let pool = PoolFit {
3169 yields: vec![(50.0, 10.0); 8],
3170 per_lesson: 300.0,
3171 latency: (1000.0, 0.1, 500.0),
3172 latency_n: LATENCY_MIN as u32,
3173 latency_reach: 24_000,
3174 not: (-1.0, 0.1),
3175 ..PoolFit::default()
3176 };
3177 let spend = |f: &Fit, limit: Option<u64>| {
3178 s.sessions.iter().map(|x| f64::from(x.checks) * pool_tokens(f, true, limit, session_seed(&x.id)) as f64).sum::<f64>() / s.sessions.len() as f64
3179 };
3180 let baseline = 10.0 * crate::whole::TOKENS as f64;
3181 let unpriced = Fit { pool: pool.clone(), ..Fit::default() };
3182 assert!(spend(&unpriced, Some(60_000)) > 1.5 * baseline, "the scenario: unpriced, the pools grow");
3183 for budget in [baseline, 1.2 * baseline, 0.9 * baseline] {
3184 let mut f = Fit { pool: pool.clone(), budget: Some(budget), ..Fit::default() };
3185 f.lambda = price(&s, &f, budget);
3186 assert!(f.lambda > 0.0, "budget {budget}: growth is priced");
3187 for limit in [None, Some(3_500), Some(60_000)] {
3188 assert!(spend(&f, limit) <= budget + 1e-6, "budget {budget}, limit {limit:?}: spent {} (λ {:e})", spend(&f, limit), f.lambda);
3189 }
3190 }
3191 }
3192
3193 #[test]
3194 fn the_pool_stays_at_the_baseline_without_evidence_or_a_limit() {
3195 let f = Fit::default();
3196 assert_eq!(pool_tokens(&f, true, Some(60_000), 1), crate::whole::TOKENS);
3197 assert_eq!(pool_tokens(&f, false, Some(60_000), 1), crate::whole::TOKENS);
3198 let mut s = planted(3, 6, 7);
3199 s.pool.bins = (0..6).map(|b| (100 * (1u64 << b), 90)).collect();
3200 s.pool.shown = 40 * 18;
3201 s.pool.tokens = 12_000 * 18;
3202 let f = fit(&s, None);
3203 assert!(pool_tokens(&f, true, Some(60_000), 1) <= crate::whole::TOKENS);
3205 assert_eq!(pool_grid(), [6000, 8485, 12000, 16971, 24000]);
3206 }
3207
3208 #[test]
3212 fn a_larger_pool_is_admitted_only_on_a_positive_gain() {
3213 let grid = pool_grid();
3214 let b = grid.iter().position(|&t| t == crate::whole::TOKENS).unwrap();
3215 let tied = PoolDraws { grid: grid.clone(), shared: vec![vec![1.0; DRAWS]; grid.len()] };
3216 let mut play = vec![0.0; grid.len()];
3217 play[grid.len() - 1] = 5.0;
3218 let feasible: Vec<bool> = (0..grid.len()).map(|i| i >= b).collect();
3220 assert_eq!(tied.choose(&play, &feasible, 0.0), crate::whole::TOKENS, "a tie at 0 keeps the baseline");
3221 let mut ahead = tied.shared.clone();
3222 ahead[grid.len() - 1] = vec![1.5; DRAWS];
3223 let ahead = PoolDraws { grid: grid.clone(), shared: ahead };
3224 assert_eq!(ahead.choose(&play, &feasible, 0.0), grid[grid.len() - 1], "a gain > 0 at every draw admits it");
3225 let mut smaller = vec![0.0; grid.len()];
3226 smaller[0] = 5.0;
3227 assert_eq!(tied.choose(&smaller, &[true; 5], 0.0), grid[0], "a smaller pool on a tie, as before");
3228 }
3229
3230 #[test]
3234 fn a_pool_grows_only_where_its_latency_was_observed() {
3235 let mut s = Summary::default();
3236 s.pool.latency = (0..204u32).map(|i| (if i / 2 < 5 { 9_500 } else { 11_000 + i / 2 }, 16_000 + 3_000 * (i % 2))).collect();
3238 s.pool.bins = vec![(400, 300); 9];
3240 (s.pool.shown, s.pool.tokens) = (100, 9_100);
3241 let mut pool = PoolFit::default();
3242 pool_state(&s, &mut pool);
3243 assert!(pool.yields.len() == 9 && pool.per_lesson == 91.0, "{pool:?}");
3244 assert_eq!((pool.latency.1, pool.latency_n), (0.0, 204), "the scenario: a flat fit, enough calls");
3245 assert!(pool.latency_reach >= 11_000 && pool.latency_reach < 12_000, "{}", pool.latency_reach);
3246 let roomy = Some(u64::MAX);
3247 assert!(pool_feasible(&pool, crate::whole::TOKENS, roomy, 0.0));
3248 for t in pool_grid().into_iter().filter(|&t| t > crate::whole::TOKENS) {
3249 assert!(!pool_feasible(&pool, t, roomy, 0.0), "{t}: no call read near it");
3250 }
3251 s.pool.latency.extend((0..LATENCY_MIN as u32).map(|i| (15_300 + i, 20_000)));
3253 pool_state(&s, &mut pool);
3254 assert_eq!(pool.latency_reach, 15_300);
3255 assert!(pool_feasible(&pool, 16_971, roomy, 0.0) && !pool_feasible(&pool, 24_000, roomy, 0.0));
3256 assert!(!pool_feasible(&PoolFit { latency_reach: 0, ..pool }, 16_971, roomy, 0.0));
3258 }
3259
3260 #[test]
3263 fn the_yields_stop_at_a_bin_no_lesson_was_shown_in() {
3264 let mut s = Summary::default();
3265 s.pool.bins = vec![(40, 20), (80, 30), (0, 0), (3, 1)];
3266 let mut pool = PoolFit::default();
3267 pool_state(&s, &mut pool);
3268 assert_eq!(pool.yields, pava(&[(21.0, 21.0), (31.0, 51.0)]), "the gap ends them");
3269 s.pool.bins[2] = (5, 1);
3270 pool_state(&s, &mut pool);
3271 assert_eq!(pool.yields.len(), 4, "filled, the tail's bin past it counts");
3272 }
3273
3274 #[test]
3275 fn pava_keeps_the_yields_from_rising() {
3276 let y = pava(&[(5.0, 5.0), (8.0, 2.0), (1.0, 9.0), (2.0, 8.0)]);
3277 let means: Vec<f64> = y.iter().map(|(a, b)| a / (a + b)).collect();
3278 assert!(means.windows(2).all(|w| w[0] >= w[1] - 1e-12), "{means:?}");
3279 assert_eq!(y[0], y[1], "the rising pair merged");
3280 assert_eq!(y[0], (13.0, 7.0));
3281 }
3282
3283 #[test]
3284 fn the_samplers_have_their_moments() {
3285 let mut rng = Rng::new(1);
3286 let n = 20_000;
3287 let z: Vec<f64> = (0..n).map(|_| normal(&mut rng)).collect();
3288 let mean = z.iter().sum::<f64>() / n as f64;
3289 let var = z.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
3290 assert!(mean.abs() < 0.03 && (var - 1.0).abs() < 0.05, "{mean} {var}");
3291 for shape in [0.4, 1.0, 3.5] {
3292 let g = (0..n).map(|_| gamma(shape, &mut rng)).sum::<f64>() / n as f64;
3293 assert!((g - shape).abs() < 0.05 * shape.max(1.0), "{shape}: {g}");
3294 }
3295 let b = (0..n).map(|_| beta_draw(2.0, 6.0, &mut rng)).sum::<f64>() / n as f64;
3296 assert!((b - 0.25).abs() < 0.01, "{b}");
3297 }
3298
3299 #[test]
3301 fn the_upper_bound_covers_the_mean_at_every_time() {
3302 let mut rng = Rng::new(21);
3303 let mut missed = 0;
3304 for _ in 0..400 {
3305 let x: Vec<f64> = (0..200).map(|_| normal(&mut rng) * 0.4 - 0.1).collect();
3306 missed += usize::from(upper_bound(&x) < -0.1);
3307 }
3308 assert!(missed <= 400 * 7 / 100, "{missed} of 400 below the mean at some time (δ = 5%)");
3309 assert_eq!(upper_bound(&[-5.0; CS_MIN - 1]), f64::INFINITY, "too few sessions to trip");
3310 let x: Vec<f64> = (0..60).map(|_| normal(&mut rng) * 0.4 - 0.3).collect();
3311 assert!(upper_bound(&x) < 0.0, "60 sessions of a clear harm trip it: {}", upper_bound(&x));
3312 }
3313
3314 fn monitored(free_helps: bool) -> Summary {
3318 summary::with_fit(monitored_sim(free_helps).0.summary(), None)
3319 }
3320
3321 fn monitored_sim(free_helps: bool) -> (Sim, Truth) {
3323 let mut t = truth();
3324 t.b[ASK] = 1.5;
3325 if free_helps {
3326 t.b[ASK] = 0.0;
3327 }
3328 let mut sim = Sim::new(31);
3329 let mut half = |grades: &[u8], _: bool, rng: &mut Rng| -> (Vec<Route>, Vec<f64>, Vec<f64>, &'static str) {
3330 let routes = grades.iter().map(|_| if rng.unit() < 0.5 { Route::Ask } else { Route::Free }).collect();
3331 (routes, vec![0.5; grades.len()], vec![0.5; grades.len()], "learned")
3332 };
3333 let mut sim_session = |sim: &mut Sim, name: &str| {
3335 for _ in 0..6 {
3336 let grades = vec![3, 2];
3337 let ids = vec![format!("L{}", sim.rng.next_u64() % 8), format!("L{}", sim.rng.next_u64() % 8)];
3338 sim.check(name, false, &grades, &ids, &t, &mut half);
3339 }
3340 };
3341 for i in 0..60 {
3342 sim_session(&mut sim, &format!("s{i}"));
3343 }
3344 (sim, t)
3345 }
3346
3347 #[test]
3353 fn a_placebo_neither_rule_delivers_moves_neither_the_estimate_nor_its_health() {
3354 const MEAN: f64 = -0.155_976_075_091_821;
3356 const UCB: f64 = -0.117_250_989_323_595;
3357 let (mut sim, t) = monitored_sim(false);
3358 let without = summary::with_fit(sim.summary(), None).monitor;
3359 let n = 12;
3362 let mut as_fixed = |grades: &[u8], _: bool, _: &mut Rng| -> (Vec<Route>, Vec<f64>, Vec<f64>, &'static str) {
3363 let routes: Vec<Route> = (0..grades.len())
3364 .map(|j| match j {
3365 j if j < conscience::CAP => Route::Free,
3366 j if j + 1 == grades.len() => Route::Placebo,
3367 _ => Route::Not,
3368 })
3369 .collect();
3370 let pf = routes.iter().map(|r| f64::from(u8::from(*r == Route::Free))).collect();
3371 (routes, vec![0.0; grades.len()], pf, "learned")
3372 };
3373 let ids: Vec<String> = (0..n).map(|i| format!("P{i}")).collect();
3374 sim.check("s59", false, &vec![1; n], &ids, &t, &mut as_fixed);
3375 let s = summary::with_fit(sim.summary(), None);
3376 let judged = |r: Route| s.applies.iter().filter(|a| a.route == r && matches!(a.label, summary::Label::Changed | summary::Label::Ignored)).count();
3377 assert_eq!(judged(Route::Placebo), 1, "the scenario: the placebo is judged");
3378 assert_eq!(fixed_routes(&ids.iter().map(|l| unit(l, 1, 0)).collect::<Vec<_>>(), &[]).iter().filter(|r| **r == Route::Free).count(), conscience::CAP);
3379 let m = &s.monitor;
3380 assert!((m.mean - MEAN).abs() < 1e-9, "mean {} vs 0.7.2's {MEAN}", m.mean);
3381 assert!(m.ucb.is_some_and(|u| (u - UCB).abs() < 1e-9), "ucb {:?} vs 0.7.2's {UCB}", m.ucb);
3382 assert_eq!((m.labelled, m.truncated), (without.labelled, without.truncated), "{m:?} vs {without:?}");
3384 assert!(m.max_weight < 10.0 && m.ess > 0.5 * f64::from(m.labelled), "{m:?}");
3385 }
3386
3387 #[test]
3392 fn one_label_does_not_carry_its_route() {
3393 for changed in [true, false] {
3394 let (mut sim, t) = monitored_sim(true);
3395 let without = summary::with_fit(sim.summary(), None).monitor;
3396 let mut deviated = |grades: &[u8], _: bool, _: &mut Rng| -> (Vec<Route>, Vec<f64>, Vec<f64>, &'static str) {
3397 (vec![Route::Ask, Route::Placebo], vec![1.0, 0.5], vec![0.0; grades.len()], "learned")
3398 };
3399 let truth = Truth { b: if changed { [9.0; P] } else { [-9.0; P] }, ..t };
3400 sim.check("s59", false, &[3, 2], &["Q0".to_string(), "Q1".to_string()], &truth, &mut deviated);
3401 let s = summary::with_fit(sim.summary(), None);
3402 let want = if changed { summary::Label::Changed } else { summary::Label::Ignored };
3403 assert_eq!(s.applies.iter().filter(|a| a.route == Route::Placebo).map(|a| a.label).collect::<Vec<_>>(), [want], "the scenario");
3404 let shift = (s.monitor.mean - without.mean).abs();
3405 assert!(shift < 0.05, "changed {changed}: one placebo moved the mean by {shift} ({} vs {})", s.monitor.mean, without.mean);
3406 }
3407 }
3408
3409 #[test]
3413 fn labels_without_leverage_do_not_open_a_route() {
3414 let n = 12;
3415 let mut as_fixed = |grades: &[u8], _: bool, _: &mut Rng| -> (Vec<Route>, Vec<f64>, Vec<f64>, &'static str) {
3416 let routes: Vec<Route> = (0..grades.len())
3417 .map(|j| match j {
3418 j if j < conscience::CAP => Route::Free,
3419 j if j + 1 == grades.len() => Route::Placebo,
3420 _ => Route::Not,
3421 })
3422 .collect();
3423 let pf = routes.iter().map(|r| f64::from(u8::from(*r == Route::Free))).collect();
3424 (routes, vec![0.0; grades.len()], pf, "learned")
3425 };
3426 let mut deviated = |grades: &[u8], _: bool, _: &mut Rng| -> (Vec<Route>, Vec<f64>, Vec<f64>, &'static str) {
3427 (vec![Route::Ask, Route::Placebo], vec![1.0, 0.5], vec![0.0; grades.len()], "learned")
3428 };
3429 for changed in [true, false] {
3430 let (mut sim, t) = monitored_sim(true);
3431 for k in 0..ROUTE_LABELS_MIN {
3432 let ids: Vec<String> = (0..n).map(|i| format!("Z{k}-{i}")).collect();
3433 sim.check(&format!("z{k}"), false, &vec![1; n], &ids, &t, &mut as_fixed);
3434 }
3435 let before = summary::with_fit(sim.summary(), None);
3436 let placebos = |s: &Summary| {
3437 s.applies.iter().filter(|a| a.route == Route::Placebo && matches!(a.label, summary::Label::Changed | summary::Label::Ignored)).count()
3438 };
3439 assert_eq!(placebos(&before), ROUTE_LABELS_MIN, "the scenario: every zero-leverage placebo judged");
3440 let truth = Truth { b: if changed { [9.0; P] } else { [-9.0; P] }, ..t };
3441 sim.check("s59", false, &[3, 2], &["Q0".to_string(), "Q1".to_string()], &truth, &mut deviated);
3442 let s = summary::with_fit(sim.summary(), None);
3443 assert_eq!(placebos(&s), ROUTE_LABELS_MIN + 1);
3444 let shift = (s.monitor.mean - before.monitor.mean).abs();
3445 assert!(shift < 0.05, "changed {changed}: one placebo moved the mean by {shift} ({} vs {})", s.monitor.mean, before.monitor.mean);
3446 }
3447 }
3448
3449 #[test]
3450 fn the_monitor_reverts_a_harmful_history_and_keeps_a_benign_one() {
3451 let harmful = monitored(false);
3452 assert!(harmful.monitor.reverted && harmful.monitor.ucb.is_some_and(|u| u < 0.0), "{:?}", harmful.monitor);
3453 assert_eq!(harmful.monitor.sessions, 60);
3454 let m = &harmful.monitor;
3457 assert!(m.labelled > 0 && m.labelled <= m.units, "{m:?}");
3458 assert!(m.ess > 0.0 && m.ess <= f64::from(m.labelled) + 1e-9 && m.max_weight >= 1.0 && m.truncated <= m.labelled, "{m:?}");
3459 let benign = monitored(true);
3460 assert!(!benign.monitor.reverted, "{:?}", benign.monitor);
3461 assert!(benign.monitor.mean > harmful.monitor.mean);
3462 }
3463
3464 #[test]
3467 fn the_fixed_routes_are_the_fixed_rule_by_position() {
3468 assert!(fixed_routes(&[], &[]).is_empty());
3469 let block = [unit("A", 1, 0), unit("B", 3, 1), unit("C", 2, 2)];
3470 assert_eq!(fixed_routes(&block, &[]), [Route::Ask; 3], "a 3 blocks: every 3, 2 and 1 up to the cap");
3471 let soft = [unit("A", 1, 0), unit("B", 2, 1)];
3472 assert_eq!(fixed_routes(&soft, &[]), [Route::Free; 2], "no 3: handed over free");
3473 let many: Vec<String> = (0..12).map(|i| format!("L{i}")).collect();
3474 let over: Vec<Unit> = many.iter().enumerate().map(|(r, l)| unit(l, 1, r)).collect();
3475 let routes = fixed_routes(&over, &[]);
3476 assert_eq!(routes.iter().filter(|r| **r == Route::Free).count(), conscience::CAP);
3477 assert_eq!(&routes[conscience::CAP..], &[Route::Not; 2], "past the cap: not now");
3478 let twice: Vec<Unit> = (0..11).map(|r| unit(if r < 9 { many[r].as_str() } else { "Z" }, 1, r)).collect();
3480 assert_eq!(&fixed_routes(&twice, &[])[9..], &[Route::Free, Route::Not]);
3481 }
3482
3483 #[test]
3484 fn a_prior_fit_writes_no_key() {
3485 assert_eq!(serde_json::to_string(&Fit::default()).unwrap(), "{}");
3486 assert_eq!(serde_json::from_str::<Fit>("{}").unwrap(), Fit::default());
3487 }
3488
3489 #[test]
3493 fn the_evidence_rule_is_chooses_without_the_test() {
3494 let f = free_beats_ask();
3495 let open = Gate { gain: vec![0.0; DRAWS], allowance: f64::INFINITY, ..Gate::default() };
3496 let ids: Vec<String> = (0..6).map(|i| format!("L{i}")).collect();
3497 let mut left = 0;
3498 for (n, g) in [(3, [2, 1, 1]), (3, [3, 2, 1]), (2, [1, 1, 0])] {
3499 let units: Vec<Unit> = (0..n).map(|r| unit(&ids[r], g[r], r)).collect();
3500 for stop in [false, true] {
3501 let check = Check { session: "new", stop, tokens: 0 };
3502 let routes = decide_on_evidence(&units, &check, &f, &[]);
3503 for seed in 0..5 {
3504 let d = choose(&units, &check, &f, &open, 0.0, seed, &[]);
3505 assert_eq!((&routes, d.xk), (&d.routes, None), "{g:?} stop {stop} seed {seed}");
3506 }
3507 left += usize::from(routes != fixed_routes(&units, &[]));
3508 assert_eq!(decide_on_evidence(&units, &check, &Fit::default(), &[]), fixed_routes(&units, &[]), "no model: the fixed rule");
3509 }
3510 }
3511 assert!(left > 0, "the scenario leaves the fixed rule");
3512 }
3513
3514 #[test]
3519 fn under_the_prior_the_evidence_rule_is_the_fixed_rule_on_every_grade_table() {
3520 let p = prior();
3521 let open = Gate { gain: vec![0.0; DRAWS], allowance: f64::INFINITY, ..Gate::default() };
3522 let ids: Vec<String> = (0..8).map(|i| format!("L{i}")).collect();
3523 let mut grades = tables(5);
3524 grades.extend([vec![2; 8], vec![1; 8], [vec![3], vec![2; 7]].concat()]);
3525 for g in &grades {
3526 let units: Vec<Unit> = ids.iter().zip(g).enumerate().map(|(r, (l, g))| unit(l, *g, r)).collect();
3527 for had in [Vec::new(), vec!["L1".to_string()]] {
3528 let want = fixed_routes(&units, &had);
3529 for stop in [true, false] {
3530 let check = Check { session: "s", stop, tokens: 0 };
3531 assert_eq!(decide_on_evidence(&units, &check, &p, &had), want, "{g:?} stop {stop} had {had:?}");
3532 let d = choose(&units, &check, &p, &open, 0.0, 7, &had);
3533 assert_eq!((&d.routes, d.source), (&want, if units.is_empty() { FIXED } else { EVIDENCE }), "{g:?} stop {stop} had {had:?}");
3534 }
3535 }
3536 }
3537 }
3538
3539 #[test]
3545 fn a_fit_with_evidence_of_dilution_deviates() {
3546 let world = |dilution: f64, sd: f64| {
3547 let mut beta = vec![0.0; P];
3548 beta[ASK] = 0.2;
3549 beta[NOT] = -1.0;
3550 beta[ASK_M] = dilution;
3551 let mut chol = vec![0.0; P * P];
3552 for k in 0..P {
3553 chol[k * P + k] = if k == ASK_M { sd } else { 0.01 };
3554 }
3555 let model = Model { beta, chol, sd: vec![1.0; P], spread: [0.01; FAMILIES], ..Model::default() };
3556 let rates = Rates { free_stop: 1.0, free_live: 1.0, later: 0.5, wait: 0.0, ask_live: 1.0 };
3557 Fit { key: "dilution".into(), model: Some(model), rates, ..Fit::default() }
3558 };
3559 let ids: Vec<String> = (0..9).map(|i| format!("L{i}")).collect();
3560 let units: Vec<Unit> = ids.iter().enumerate().map(|(r, l)| unit(l, if r == 0 { 3 } else { 2 }, r)).collect();
3561 let base = fixed_routes(&units, &[]);
3562 assert_eq!(base, [Route::Ask; 9], "the scenario: the fixed rule asks all nine");
3563 let gate = open_gate();
3564 for stop in [false, true] {
3565 let check = Check { session: "n", stop, tokens: 0 };
3566 let d = choose(&units, &check, &world(-1.0, 0.01), &gate, ALPHA, 1, &[]);
3567 assert_eq!((d.source, d.xk, d.xs), (EVIDENCE, None, None), "{d:?}");
3568 assert_ne!(d.routes, base, "{d:?}");
3569 assert!(d.routes.contains(&Route::Free) && d.routes.contains(&Route::Ask), "asks few, hands over the rest: {d:?}");
3570 assert_eq!(d.p, d.routes.iter().map(|r| f64::from(u8::from(*r == Route::Ask))).collect::<Vec<_>>(), "exact");
3571 assert_eq!(d.pf, d.routes.iter().map(|r| f64::from(u8::from(*r == Route::Free))).collect::<Vec<_>>(), "exact");
3572 let wide = world(-0.2, 0.5);
3574 let m = wide.model.as_ref().expect("a model");
3575 let feats = Feat::of(&units, stop, 0);
3576 let t = theta_mean(m, &feats, &units, &Levels::of(check.session, &m.judge, &units));
3577 let at_mode = solve(&Table::of(&Valuer { t: &t, feats: &feats, rates: &wide.rates }, &[false; 9]), crate::attention::RAIL, conscience::CAP);
3578 assert_ne!(at_mode, base, "stop {stop}");
3579 for f in [wide, world(0.0, 0.01)] {
3580 assert_eq!(decide_on_evidence(&units, &check, &f, &[]), base, "stop {stop}");
3581 }
3582 }
3583 }
3584
3585 fn tested_at(units: &[Unit], had: &[String], seeds: u64) -> Vec<Decision> {
3588 let p = prior();
3589 let open = Gate { gain: vec![0.0; DRAWS], allowance: 1e9, ..Gate::default() };
3590 (0..seeds).map(|seed| choose(units, &Check { session: "s", stop: false, tokens: 0 }, &p, &open, ALPHA, seed, had)).collect()
3591 }
3592
3593 #[test]
3598 fn the_tests_count_is_uniform_and_its_propensities_exact() {
3599 let ids: Vec<String> = (0..5).map(|i| format!("L{i}")).collect();
3600 let units: Vec<Unit> = ids.iter().zip([1, 2, 1, 2, 1]).enumerate().map(|(r, (l, g))| unit(l, g, r)).collect();
3601 let order = [1, 3, 0, 2, 4];
3602 let n = 1200;
3603 let mut counts = [0usize; 6];
3604 for d in tested_at(&units, &[], n) {
3605 let (Some(5), Some(s)) = (d.xk, d.xs) else { panic!("{d:?}") };
3606 let s = s as usize;
3607 counts[s] += 1;
3608 for (i, &j) in order.iter().enumerate() {
3609 assert_eq!(d.routes[j], if i < s { Route::Ask } else { Route::Free }, "{d:?}");
3610 assert_eq!((d.p[j], d.pf[j]), (outcome::round_p((5 - i) as f64 / 6.0), outcome::round_p((i + 1) as f64 / 6.0)), "{d:?}");
3611 }
3612 assert_eq!(d.source, if s == 0 { EXPERIMENT_CONTROL } else { EXPERIMENT }, "{d:?}");
3613 }
3614 let q = 1.0 / 6.0;
3615 let se = (q * (1.0 - q) / n as f64).sqrt();
3616 for (s, c) in counts.iter().enumerate() {
3617 let freq = *c as f64 / n as f64;
3618 assert!((freq - q).abs() <= 4.0 * se, "s = {s}: {freq:.4} against {q:.4} ({counts:?})");
3619 }
3620 }
3621
3622 #[test]
3625 fn the_test_never_touches_the_stop_or_a_three() {
3626 let ids: Vec<String> = (0..3).map(|i| format!("L{i}")).collect();
3627 let units: Vec<Unit> = ids.iter().zip([2, 3, 1]).enumerate().map(|(r, (l, g))| unit(l, g, r)).collect();
3628 let base = fixed_routes(&units, &[]);
3629 let p = prior();
3630 let open = Gate { gain: vec![0.0; DRAWS], allowance: 1e9, ..Gate::default() };
3631 for seed in 0..40 {
3632 let d = choose(&units, &Check { session: "s", stop: true, tokens: 0 }, &p, &open, ALPHA, seed, &[]);
3633 assert_eq!((&d.routes, d.source, d.xk, d.xs), (&base, EVIDENCE, None, None), "{d:?}");
3634 }
3635 let mut seen = std::collections::BTreeSet::new();
3636 for d in tested_at(&units, &[], 60) {
3637 assert_eq!((d.routes[1], d.p[1], d.pf[1], d.xk), (Route::Ask, 1.0, 0.0, Some(2)), "{d:?}");
3638 let s = d.xs.unwrap_or(9);
3639 assert_eq!(d.source == EXPERIMENT_CONTROL, s == 2, "{d:?}");
3640 seen.insert(s);
3641 }
3642 assert_eq!(seen.len(), 3, "every count drawn: {seen:?}");
3643 }
3644
3645 #[test]
3648 fn the_test_never_hands_over_a_lesson_the_session_had() {
3649 let ids: Vec<String> = (0..4).map(|i| format!("L{i}")).collect();
3650 let had = ["L2".to_string()];
3651 let soft: Vec<Unit> = ids.iter().zip([2, 1, 2, 1]).enumerate().map(|(r, (l, g))| unit(l, g, r)).collect();
3652 let block: Vec<Unit> = ids.iter().zip([2, 3, 2, 1]).enumerate().map(|(r, (l, g))| unit(l, g, r)).collect();
3653 for (units, kept) in [(&soft, Route::Not), (&block, Route::Ask)] {
3654 let mut moved = 0;
3655 for d in tested_at(units, &had, 60) {
3656 assert_eq!((d.routes[2], d.pf[2], d.xk), (kept, 0.0, Some(if kept == Route::Not { 3 } else { 2 })), "{d:?}");
3657 moved += usize::from(d.source == EXPERIMENT);
3658 }
3659 assert!(moved > 0, "the scenario: the test ran");
3660 }
3661 }
3662
3663 #[test]
3667 fn the_gate_refuses_the_test_when_short_and_risk_zero_turns_it_off() {
3668 let ids: Vec<String> = (0..3).map(|i| format!("L{i}")).collect();
3669 let units: Vec<Unit> = ids.iter().zip([2, 1, 1]).enumerate().map(|(r, (l, g))| unit(l, g, r)).collect();
3670 let base = fixed_routes(&units, &[]);
3671 let exact = |r: Route| base.iter().map(|x| f64::from(u8::from(*x == r))).collect::<Vec<_>>();
3672 let p = prior();
3673 let short = Gate { gain: vec![0.0; DRAWS], allowance: 1e-12, ..Gate::default() };
3674 let check = Check { session: "s", stop: false, tokens: 0 };
3675 let (mut refused, mut control) = (0, 0);
3676 for seed in 0..40 {
3677 let d = choose(&units, &check, &p, &short, ALPHA, seed, &[]);
3678 assert_eq!((&d.routes, &d.p, &d.pf, d.risk, d.xk), (&base, &exact(Route::Ask), &exact(Route::Free), 0.0, Some(3)), "{d:?}");
3679 match d.xs {
3680 Some(0) => control += usize::from(d.source == EXPERIMENT_CONTROL),
3681 _ => refused += usize::from(d.source == GATE),
3682 }
3683 let off = choose(&units, &check, &p, &open_gate(), 0.0, seed, &[]);
3684 assert_eq!((&off.routes, off.source, off.xk, off.xs), (&base, EVIDENCE, None, None), "{off:?}");
3685 }
3686 assert!(refused > 0 && control > 0 && refused + control == 40, "refused {refused}, control {control}");
3687 let room = Gate { allowance: 1e9 * ALPHA, ..short };
3690 assert!((0..40).any(|seed| choose(&units, &check, &p, &room, ALPHA, seed, &[]).source == EXPERIMENT));
3691 assert!((0..40).all(|seed| choose(&units, &check, &p, &room, 1e-12, seed, &[]).source != EXPERIMENT), "α scales it down");
3692 }
3693
3694 #[test]
3699 fn risk_zero_refuses_an_evidence_deviation_the_allowance_alone_would_cover() {
3700 let f = free_beats_ask();
3701 let ids: Vec<String> = (0..4).map(|i| format!("L{i}")).collect();
3703 let units: Vec<Unit> = ids.iter().enumerate().map(|(r, l)| unit(l, if r == 0 { 3 } else { 2 }, r)).collect();
3704 let base = fixed_routes(&units, &[]);
3705 let check = Check { session: "n", stop: false, tokens: 0 };
3706 let loss = 2.0 * units.len() as f64;
3709 let gate = Gate { gain: vec![-loss; DRAWS], allowance: 2.0 * loss, ..Gate::default() };
3710 let at = choose(&units, &check, &f, &gate, ALPHA, 1, &[]);
3711 assert_eq!(at.source, EVIDENCE, "the scenario: the allowance admits the deviation at ALPHA: {at:?}");
3712 assert_ne!(at.routes, base, "{at:?}");
3713 let off = choose(&units, &check, &f, &gate, 0.0, 1, &[]);
3714 assert_eq!((&off.routes, off.source, off.risk, off.gate, off.xk), (&base, GATE, 0.0, 0.0, None), "{off:?}");
3715 }
3716
3717 #[test]
3720 fn the_dilution_prior_has_its_mode_at_zero() {
3721 let c = coefs();
3722 for k in [ASK_M, FREE_M] {
3723 assert_eq!((c[k].mean, c[k].sd, c[k].nonpos, c[k].ard), (0.0, 0.1, true, false), "{}", NAMES[k]);
3724 }
3725 let p = prior();
3726 let m = p.model.as_ref().expect("the prior is a model");
3727 let units = [unit("A", 2, 0), unit("B", 2, 1), unit("C", 2, 2)];
3728 let feats = Feat::of(&units, false, 0);
3729 let t = theta_mean(m, &feats, &units, &Levels::of("s", &m.judge, &units));
3730 let v = Valuer { t: &t, feats: &feats, rates: &p.rates };
3731 assert!(v.ask(0, 1) > 0.0 && (v.ask(0, 1) - v.ask(0, 3)).abs() < 1e-12, "{} vs {}", v.ask(0, 1), v.ask(0, 3));
3732 assert!((v.free(0, 1) - v.free(0, 3)).abs() < 1e-12);
3733 }
3734
3735 #[test]
3739 fn a_bounded_slope_is_drawn_from_its_truncated_prior() {
3740 let p = prior();
3741 let m = p.model.as_ref().expect("the prior is a model");
3742 const N: u64 = 4000;
3743 let draws: Vec<[f64; P]> = (0..N).map(|k| draw_beta(m, gate_seed(&p, k as usize))).collect();
3744 let half = -DILUTION_PRIOR.1 * (2.0 / std::f64::consts::PI).sqrt();
3745 for k in [ASK_M, FREE_M] {
3747 assert!(draws.iter().all(|b| b[k] < 0.0), "{}: a draw at 0", NAMES[k]);
3748 let mean = draws.iter().map(|b| b[k]).sum::<f64>() / N as f64;
3749 assert!((mean - half).abs() < 0.005, "{}: mean {mean} against {half}", NAMES[k]);
3750 }
3751 let ask = draws.iter().map(|b| b[ASK]).sum::<f64>() / N as f64;
3752 assert!((ask - ASK_PRIOR.0).abs() < 0.03, "ask {ask}");
3753 for z in [-2.0, -0.3, 0.0, 1.0, 2.5] {
3755 assert!((below_zero(-1.0, 0.1, z) - (-1.0 + 0.1 * z)).abs() < 1e-6, "z {z}");
3756 let x = below_zero(50.0, 0.1, z);
3757 assert!(x <= 0.0 && x > -0.01, "z {z}: {x}");
3758 }
3759 assert!(below_zero(0.0, 0.1, -1.0) < below_zero(0.0, 0.1, 1.0), "monotone in z");
3760 assert!((phi(1.959_964) - 0.975).abs() < 1e-6 && (probit(0.975f64.ln(), 0.025) - 1.959_964).abs() < 1e-5);
3761 }
3762
3763 #[test]
3766 fn the_prior_is_a_model_of_the_priors_alone() {
3767 let p = prior();
3768 let m = p.model.as_ref().expect("the prior is a model");
3769 assert!(m.current() && m.converged && p.key.is_empty() && p.labels == 0);
3770 for (k, c) in coefs().iter().enumerate() {
3771 assert_eq!(m.beta[k], if c.nonpos { c.mean.min(0.0) } else { c.mean }, "{}", NAMES[k]);
3772 assert!((m.chol[k * P + k] - c.sd).abs() < 1e-12, "{}", NAMES[k]);
3773 }
3774 assert_eq!(m.spread, [LEVEL_START; FAMILIES]);
3775 assert_eq!(p.rates, Rates { free_stop: 0.5, free_live: 0.5, later: 0.5, wait: 0.5, ask_live: 1.0 });
3776 assert!(fit(&Summary::default(), None).model.is_none());
3777 }
3778
3779 #[test]
3782 fn a_prediction_is_the_posterior_chance_of_a_change() {
3783 let f = Feat { grade: 3, rank: 1, stop: true, three: true, pool: 0.0 };
3784 let mut beta = vec![0.0; P];
3785 beta[B0] = 0.8;
3786 beta[G3] = 0.4;
3787 beta[ASK] = 0.3;
3788 let lessons = BTreeMap::from([("A".to_string(), (0.5, 0.0))]);
3789 let certain = Model { beta: beta.clone(), chol: vec![0.0; P * P], sd: vec![1.0; P], spread: [0.0; FAMILIES], lessons, ..Model::default() };
3790 let mut fit = Fit { model: Some(certain.clone()), ..Fit::default() };
3791 let eta = dot(&std::array::from_fn(|k| beta[k]), &design(&f, Route::Ask, 2)) + 0.5;
3792 assert!((predict(&fit, &f, Route::Ask, 2, "s", "A").unwrap() - sigmoid(eta)).abs() < 1e-12);
3793 let mut wide = certain.clone();
3794 for k in 0..P {
3795 wide.chol[k * P + k] = 1.0;
3796 }
3797 wide.spread = [0.5; FAMILIES];
3798 fit.model = Some(wide);
3799 let p = predict(&fit, &f, Route::Ask, 2, "s", "unseen").unwrap();
3800 assert!(p > 0.5 && p < sigmoid(eta - 0.5), "{p} against {}", sigmoid(eta - 0.5));
3801 fit.model = Some(Model { beta: vec![0.0; P - 1], ..certain });
3802 assert_eq!(predict(&fit, &f, Route::Ask, 2, "s", "A"), None);
3803 }
3804}