Skip to main content

borg/select/
policy.rs

1//! The learned policy's core: a Bayesian model of what each route of a graded apply is worth, fitted
2//! from the outcome log, and the decision it drives at one check. Pure: no file, no clock, no
3//! environment (an engineer's budget comes in as an argument).
4//!
5//! **The value model.** One logistic model of the judge's verdict "the lesson changed the work and the
6//! change avoids its consequence" (`asked_then_changed`), per graded apply j and route a: ask (Q),
7//! free (F) or not now (D, measured by the placebo arm, select.rs `PLACEBO_SLOTS`):
8//! - η_j = β₀ + β₂[g=2] + β₃[g=3] + β_rank·ln(1+rank) + β_stop[stop] + β_three[a 3 in the check]
9//!   + β_three_stop[a 3 in a stop's check] + β_pool·ln(tokens/12k) + u_session + w_lesson + γ_judge;
10//! - P(changed | a, m) = σ(η_j + δ_a + κ_a·ln m_a + interactions), δ_F = 0 (the reference), m_a the
11//!   number of applies the check routes to a (dilution: more questions, or more free context, make
12//!   each one less likely to change the work), κ_D = 0, interactions δ_Q×stop, δ_Q×[g=3], δ_D×(g−2).
13//!
14//! The fixed rule asks exactly when a check has a 3, so in its history a route term and the cell of
15//! checks it is asked in are one column. Each route term therefore has its twin among the effects that
16//! do not depend on the route (δ_Q: β_three; δ_Q×stop: β_three_stop; δ_Q×[g=3]: β₃): what the fixed
17//! rule's cells differ by goes to the twin, and a route term learns only from checks whose routes
18//! varied within a cell, never from the fixed rule's history alone.
19//!
20//! Fitted by penalised Newton (MAP) over the fixed effects and the session, lesson and judge effects
21//! together (the largest family eliminated exactly by its Schur complement), with the Laplace
22//! covariance; empirical Bayes (EM, type-II maximum likelihood) for the spread of each family and
23//! automatic relevance (ARD) for the terms without a dated prior (the rank slope, the pool slope and
24//! the interactions), so a weak feature gets weight only when the data support it. A term with a dated
25//! prior (ask and not now against free, `ASK_PRIOR`, `NOT_PRIOR`; the dilution slopes,
26//! `DILUTION_PRIOR`) keeps it: EM would shrink its sd wherever the data cannot tell the term from the
27//! others (under the fixed rule how many are asked or handed over is set by the check's size, never
28//! chosen), which is the data saying nothing, not the effect being small. Dilution and pool slopes are
29//! ≤ 0 (an active set). Priors in `coefs`. No importance weights for the routes: the randomisation
30//! depends only on logged covariates, so the design is ignorable. The judge's missingness is modelled
31//! (`judged`) and enters as truncated inverse-probability weights. Labels fade with age in sessions,
32//! 2^(−age/H), H chosen from `HALF_LIVES` by prequential likelihood over whole sessions (`half_life`).
33//! The fit runs at `borg learn` (select/summary.rs `refit`, main.rs `learn_store`, which a session's
34//! end starts detached), never in a hook. `fit_pruned` is P2's pre-registered pruned model, for
35//! evaluation only.
36//!
37//! **The decision** (`choose`, policy v2: docs/evidence.md, Rules v2). The value of route a for apply
38//! j is τ_j(a, m) = π_a·[P(changed | a, m) − P(changed | D)], with the learned delivery rate π (a stop's
39//! free lesson arrives only if a next prompt comes, a live question only if it reaches the drone; a
40//! stop's question always does, it blocks) (`Rates`). Not now is worth 0, at the stop and during the
41//! turn alike: a lesson left for later earns its value at the check that delivers it, and crediting it
42//! here too would count that delivery twice in the gate's ledger. A check takes one of three routings:
43//! - **On evidence** (`evident`): the routes maximising Σ_j τ_j (`solve`, exact) under the posterior
44//!   mean (`theta_mean`: the coefficients' and every level's mean, no draw), played only when they
45//!   beat the fixed rule at the `DELTA` quantile over the `DRAWS` joint draws (`gate_seed`, the ledger's
46//!   draws), a lesson the session already had never handed over free (its delivery would drop it).
47//!   Without evidence nothing passes the quantile, so the prior alone never leaves the fixed rule: the
48//!   effects the fixed rule's history cannot identify (how much each question dilutes the others, what
49//!   a lesson not delivered still changes) cannot decide from their priors. No Thompson play.
50//! - **The randomised question-count test**, on a live check (never the stop) whose evidence keeps the
51//!   fixed rule: of the fixed rule's k delivered non-3 lessons that may go free (`tested`), the first s
52//!   in its order are asked and the rest handed over, s uniform in 0..=k drawn from the check's seed
53//!   (`draw_count`). Nothing is withheld and every 3 is asked as the fixed rule asks it, so recall is
54//!   the fixed rule's: on a check with a 3 the hand-overs replace questions, and the free delivery
55//!   carries them past its character budget as the questions would have been carried (hook.rs
56//!   `INSTEAD`). The fixed rule is s = 0 without a 3 and s = k with one (`EXPERIMENT_CONTROL`).
57//!   It measures what the fixed rule's history never varies: how many questions to ask.
58//! - **The fixed rule** otherwise.
59//!
60//! The conservative gate (in `choose`) keeps the fixed rule unless the deviation leaves the replayed
61//! value of the policy's checks, at the `DELTA` quantile over `DRAWS` joint draws, at most α × the
62//! fixed rule's own replayed value below the fixed rule (select/summary.rs `Gate`), α the engineer's
63//! tolerance (`ALPHA` unless the collective sets `risk`, select.rs `risk_of`; 0 turns the test off):
64//! cold start is exactly the fixed rule, and the test is rationed by quality, not by an ε. A stop's
65//! grade 3 is always asked (`forced`). Propensities are exact: 0 or 1 on evidence and under the fixed
66//! rule, and under the test each unit's share of the k + 1 values of s that ask it or hand it over (a
67//! value the gate refuses counting as the fixed rule's routes). Spend is not an objective: only the
68//! pool's size has a price, and only under a budget an engineer set for the collective (`price`);
69//! without one the price is 0.
70//!
71//! **The monitor** (`monitor`) estimates, per session, the policy's value against the fixed rule
72//! (doubly robust, cross-fitted by session, truncated self-normalised weights) and keeps a time-uniform
73//! confidence sequence on it (`upper_bound`); when its bound shows harm beyond `ALPHA` of the fixed
74//! rule's value, the store reverts to the fixed rule for good (select/summary.rs `REVERTED`).
75
76use 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
85/// The share of the fixed rule's replayed quality (mistakes avoided) the learned policy may risk below
86/// it while it learns, at the gate (`choose`, unless the collective sets `risk`, select.rs `risk_of`)
87/// and in the monitor (`monitor`), and the share the gate's ledger accrues (`ledger_add_without`).
88/// Method: a bound on quality, not on cost; the engineer's tolerance for learning, stated once. Kept by
89/// dated decision (2026-10-31), with the allowance scaling with the fixed rule's modelled value: about one
90/// deviation in 9 to 10 checks in both main stores.
91pub const ALPHA: f64 = 0.10;
92/// The gate's and the evidence rule's quantile, and the monitor's error probability. Method: the
93/// conventional 95% confidence.
94pub const DELTA: f64 = 0.05;
95/// Joint posterior draws behind the gate's and the evidence rule's quantile: conscience.rs `P_DRAWS`
96/// (method, its standard error ≤ 0.022).
97pub const DRAWS: usize = conscience::P_DRAWS;
98/// Applies a check may have for the learned decision; a larger check takes the fixed rule (source
99/// `size`). Guard: bounds the exact solver's time per check (O(n³ log n), on the posterior mean's
100/// table) and the valuing of the evidence rule's and the test's routes (O(n) per draw, `DRAWS` draws,
101/// k + 2 routings); its hit rate is the share of `size` checks.
102pub const SOLVE_MAX: usize = 16;
103
104/// Prior sd of every identified effect (intercept, grades, channel, a 3 in the check, a 3 at the
105/// stop): N(0, 2.5²). Method: the weakly informative default for logistic coefficients (Gelman et al.
106/// 2008); also the ceiling of every learned spread.
107pub const PRIOR_SD: f64 = 2.5;
108/// Ask against free, N(0, 0.35²). Learned, dated prior: the review→fix evaluation of 2026-10 (asked
109/// +6.3, handed over +7.3 points against the plain agent) puts them within a few points; wide.
110pub const ASK_PRIOR: (f64, f64) = (0.0, 0.35);
111/// Not now against free, N(−0.3, 0.25²). Learned, dated prior: the same evaluation, a lesson not
112/// delivered changes less than one handed over. Fixed by dated decision (2026-10-31, docs/evidence.md):
113/// 8 placebos drawn, 1 judged, the fitted effect is this prior; withholding is not learned until 100
114/// judged placebos exist.
115pub const NOT_PRIOR: (f64, f64) = (-0.3, 0.25);
116/// The judged placebos needed before not now is learned rather than fixed at [`NOT_PRIOR`] (the same
117/// dated decision, 2026-10-31): shown as progress on the Conscience tab.
118pub const NOT_LEARNED_UNTIL: u64 = 100;
119/// Dilution slope per ln(m) of asks or free lessons, N(0, 0.1²) truncated ≤ 0 (half-normal, its mode
120/// at 0). Learned, dated prior: the 2026-10-22 estimate (0.04 [0, 0.12] less per extra question on the
121/// log-odds scale) has an interval that includes 0, so no dilution is assumed; its width sets the scale.
122pub const DILUTION_PRIOR: (f64, f64) = (0.0, 0.1);
123/// Starting sd of an ARD term, every term without a dated prior (rank, interactions, pool). Method: EM
124/// moves it.
125pub const ARD_START: f64 = 1.0;
126/// Starting spread of the session, lesson and judge effects. Method: EM moves it.
127pub const LEVEL_START: f64 = 0.5;
128/// Floor of every learned spread. Method: EM's update can never leave 0, and 0.01 on the log-odds
129/// scale is no effect.
130pub const SD_FLOOR: f64 = 0.01;
131/// Half-lives of a label's weight, in sessions, the forgetting is chosen from (None: none). Method: a
132/// doubling grid; a tie goes to the longer.
133pub const HALF_LIVES: [Option<u32>; 5] = [None, Some(64), Some(32), Some(16), Some(8)];
134/// Sessions a fit reads at most, the newest. Guard: bounds the fit's dense block (one effect per
135/// session) and its time; the labels it leaves out are counted (`Fit::dropped`).
136pub const FIT_SESSIONS: usize = 256;
137/// The largest inverse-probability weight of a judged label. Guard: a route the judge rarely reaches
138/// would otherwise let a handful of its labels outweigh the rest.
139pub const MAX_IPW: f64 = 10.0;
140/// The least chance a placebo that happened is weighed at (`monitor`, select/replay.rs P5). Method: a
141/// logged ψ of 0 for a drawn placebo is rounding (outcome.rs `round_p`), so it is read as 1/1000 at
142/// least: such a label weighs at most a thousand sure ones.
143pub const PSI_FLOOR: f64 = 1e-3;
144/// The pool budgets the policy chooses among: whole.rs `TOKENS` × 2^(i/2). Method: a √2 grid around
145/// the baseline.
146pub const POOL_STEPS: [i32; 5] = [-2, -1, 0, 1, 2];
147/// Latency observations needed before the pool may grow past the baseline. Method: with 20, the
148/// empirical 95% quantile has an observation above it.
149pub const LATENCY_MIN: usize = 20;
150/// The share of a budget a call must have read to count as latency observed at that budget
151/// (`PoolFit::latency_reach`, `pool_feasible`). Guard, dated 2026-10-31: a pool grows only to a budget
152/// its latency was measured at, never on a slope extrapolated past the calls (a team store's fit was
153/// flat, b = 0, and would have let a 16,971-token pool in on 12,000-token calls alone). At 0.9 a pool
154/// filled to its budget counts: it stops within a lesson of it (about 90 tokens there), and 193 of
155/// that store's 203 calls at the 12,000 baseline read 10,800 or more.
156pub const LATENCY_NEAR: f64 = 0.9;
157
158/// The labelled applies with leverage (the taken route's difference between the policy and the fixed
159/// rule is not 0) a route needs before the monitor uses its weighted residual (`monitor`); below it the
160/// direct-method term (the cross-fitted outcome model) carries the route alone. Guard, dated
161/// 2026-10-31: weights are normalised per route, so a route's only label carries the whole route (a
162/// team store's not-now route: 1 judged placebo at 42× the mean weight, ESS 9 of 127); at 5 one label
163/// is at most about a fifth of its route's residual. Labels without leverage do not count: they move
164/// no residual, so 5 of them would leave the next label with leverage carrying the route alone. Below it a route's residual is noise one verdict
165/// decides, so dropping it loses no evidence a confidence sequence could use.
166pub const ROUTE_LABELS_MIN: usize = 5;
167
168/// Newton's convergence: the largest step below this. Method.
169const TOL: f64 = 1e-8;
170/// Newton steps per fit at most. Guard: a fit that does not converge stops here (`Model::converged`).
171const MAX_NEWTON: usize = 50;
172/// Step halvings per Newton step at most. Guard: a step that cannot improve the objective (at the
173/// optimum, to float precision) ends the fit instead of halving forever.
174const MAX_HALVINGS: usize = 30;
175/// EM rounds of the spreads per fit at most. Guard: EM's slow crawl toward a spread of 0 stops here.
176const MAX_EB: usize = 50;
177/// EM's convergence: the largest relative change of a spread below this. Method.
178const EB_TOL: f64 = 1e-4;
179/// Sessions the prequential choice of the half-life predicts at most, the newest. Guard: one fit per
180/// session and half-life.
181const PREQ_SESSIONS: usize = 16;
182/// The sessions at which the monitor's confidence sequence is tightest: the pre-registered online
183/// readout's 30 sessions. Method: it sets the normal mixture's ρ, not the error rate.
184const CS_SESSIONS: f64 = 30.0;
185/// Sessions before the monitor may trip. Method: the sequence plugs in the increments' variance,
186/// which needs a few sessions to mean anything.
187const CS_MIN: usize = 10;
188/// Bisection steps of the budget's price. Method: enough halvings for f64 precision.
189const PRICE_STEPS: usize = 60;
190
191// The value model's coefficients (module doc). Protocol: their places in `Model::beta`.
192const 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;
207/// The value model's coefficients. Protocol: the layout of `Model::beta` (a cached model of another
208/// layout is not read, `Model::current`).
209pub const P: usize = 15;
210/// Their names, for `borg debug select` and the monitoring, in `Model::beta`'s order.
211pub 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];
228/// The random-effect families, in `Model`'s order. Protocol.
229const SESSION: usize = 0;
230const LESSON: usize = 1;
231const JUDGE: usize = 2;
232const FAMILIES: usize = 3;
233
234/// A coefficient's prior: N(mean, sd²), its sd learned (ARD) or fixed, bounded above by 0 or not.
235#[derive(Debug, Clone, Copy)]
236struct Coef {
237    mean: f64,
238    sd: f64,
239    ard: bool,
240    nonpos: bool,
241}
242
243/// The value model's priors (module doc, constants above): a dated prior keeps its sd, ARD learns the
244/// sd of the terms without one.
245fn 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
261/// The terms P2's pruned model keeps (`fit_pruned`): grade (the intercept and both grades), route (ask
262/// and not now against free) and dilution. Method: the pre-registered prune (tasks/todo.md, P2).
263const PRUNED_KEEPS: [usize; 7] = [B0, G2, G3, ASK, NOT, ASK_M, FREE_M];
264
265/// The priors of P2's pruned model: `coefs` for the terms it keeps, every other held at N(0,
266/// `SD_FLOOR`²) without ARD, which could bring it back: out of the model.
267fn 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/// What the value model knows of a graded apply, its route aside.
278#[derive(Debug, Clone, Copy, PartialEq)]
279pub struct Feat {
280    pub grade: u8,
281    pub rank: usize,
282    /// The check is the stop's (else live).
283    pub stop: bool,
284    /// The check has a 3.
285    pub three: bool,
286    /// ln(pool tokens / whole.rs `TOKENS`), 0 when unknown.
287    pub pool: f64,
288}
289
290impl Feat {
291    /// The features of `units` at a check on `stop` with a pool of `tokens` (0 unknown).
292    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
299/// ln(tokens / whole.rs `TOKENS`), 0 for an unknown pool (a record from before pool sizes).
300fn pool_x(tokens: u32) -> f64 {
301    if tokens == 0 { 0.0 } else { (f64::from(tokens) / crate::whole::TOKENS as f64).ln() }
302}
303
304/// The value model's row for apply `f` on `route` among `m` of its route.
305fn 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
346/// ln(1 + eˣ), without overflow.
347fn log1pexp(x: f64) -> f64 {
348    if x > 0.0 { x + (-x).exp().ln_1p() } else { x.exp().ln_1p() }
349}
350
351/// The lower Cholesky factor of the symmetric positive definite `a` (n×n, row-major), in place; false
352/// when it is not positive definite.
353fn 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
372/// Solves L Lᵀ x = b in place, `l` from `cholesky`.
373fn 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
384/// The inverse of L Lᵀ (n×n, row-major).
385fn 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
399/// A standard normal draw (Box-Muller, one of the pair): O(1).
400fn 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
405/// A Gamma(shape, 1) draw (Marsaglia & Tsang 2000; shape < 1 by the boost U^(1/shape)): O(1) expected.
406fn 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    // Guard: each round accepts with probability over 0.95, so 64 rounds never all reject in practice;
413    // the mode-like d stands in if they do.
414    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
427/// A Beta(a, b) draw from two Gamma draws.
428fn 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
433/// A stable 64-bit hash of `parts` (blake3, separated), for seeds.
434fn 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
446/// A seed for draw `i` of the stream `seed` (SplitMix64 of both).
447fn 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/// One observation of a logistic model: features, label, weight, and its level in each family.
452#[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/// A fitted logistic model: the MAP and its Laplace covariance.
461#[derive(Debug, Clone)]
462struct Est<const K: usize, const F: usize> {
463    beta: [f64; K],
464    u: [Vec<f64>; F],
465    /// The prior sds the fit used (ARD's learned for theirs) and the families' spreads.
466    sd: [f64; K],
467    spread: [f64; F],
468    /// The marginal covariance of `beta`, and each level's marginal variance.
469    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
480/// The MAP of the penalised logistic model over `obs` (`levels[f]` levels in family f), by Newton
481/// steps over every parameter at once: the family with the most levels is eliminated exactly (its
482/// levels are independent given the rest), the rest solved densely. Deterministic. None when the
483/// system is singular (cannot happen with proper priors; a guard).
484fn 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        // The precision (negative Hessian) and gradient at the current point.
542        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        // The eliminated family: its own precision, gradient and coupling to the dense block.
573        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        // The step: a Newton step on the reduced system, with the bounded coefficients at 0 held there
606        // while the gradient pushes them up (an active set).
607        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        // Halve the step until the objective does not fall.
639        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            // Not below the current objective beyond float noise (relative 1e-12).
654            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    // The Laplace covariance: the inverse of the reduced precision, and each eliminated level's.
667    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
688/// Adds `v` at `i` of a short sparse vector.
689fn 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
699/// `map` with its spreads and ARD sds learned by EM (type-II maximum likelihood): each family's spread
700/// is the root mean of its levels' u² + var, each ARD coefficient's sd √((β − mean)² + var), within
701/// [`SD_FLOOR`, `PRIOR_SD`].
702fn 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/// The learned delivery rates a route's value uses (module doc), posterior means of Beta(1, 1) with
726/// the fit's forgetting.
727#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
728pub struct Rates {
729    /// A stop's free lesson was delivered (a next prompt came).
730    pub free_stop: f64,
731    /// A live check's free lesson was delivered.
732    pub free_live: f64,
733    /// A lesson a live check left for later was delivered later in the session: shown, not used (not
734    /// now is worth 0, `Valuer::value`; the later delivery earns its value at its own check).
735    pub later: f64,
736    /// A live question was still waiting at the stop and asked there in a block: shown, not used.
737    pub wait: f64,
738    /// A live question of a check with a 3, of a lesson its session lacked, reached the drone, during
739    /// the turn or waiting for the stop and asked in its block (select/summary.rs `ASK_LIVE`): such an
740    /// ask's rate in its value (`Valuer::ask`; a stop's is 1, it blocks; one of a live check without a
741    /// 3 no stop asks, so it arrives as a hand-over does, at `free_live`). 1 until one is tallied
742    /// (baseline: the fixed rule takes every question as delivered, as a stop's is).
743    pub ask_live: f64,
744}
745
746/// The pool's learned state at the fit (`pool_tokens`).
747#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
748pub struct PoolFit {
749    /// Per log2 bin of fill position, the yield's Beta posterior (shown lessons applied), monotone
750    /// non-increasing in position (pool adjacent violators over the bins).
751    #[serde(default, skip_serializing_if = "Vec::is_empty")]
752    pub yields: Vec<(f64, f64)>,
753    /// Pool tokens per shown lesson.
754    #[serde(default, skip_serializing_if = "is_default")]
755    pub per_lesson: f64,
756    /// The model call's duration in ms against its pool tokens: a + b·tokens, plus the residuals'
757    /// 95% quantile, from `latency_n` calls.
758    #[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    /// The largest pool tokens `LATENCY_MIN` of those calls read at least (the `LATENCY_MIN`-th
763    /// largest; 0 with fewer calls): a budget above the baseline is feasible only up to this over
764    /// `LATENCY_NEAR`, where the latency fit is read among calls, not extrapolated.
765    #[serde(default, skip_serializing_if = "is_default")]
766    pub latency_reach: u32,
767    /// The model's mean η of a labelled apply, its pool slope and its "not now" effect (mean, sd):
768    /// what a lesson more in the pool is worth.
769    #[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/// The fitted value model (module doc): its MAP and draws' ingredients.
778#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
779pub struct Model {
780    /// The coefficients (`NAMES`) and the lower Cholesky factor of their marginal covariance (P×P,
781    /// row-major).
782    pub beta: Vec<f64>,
783    pub chol: Vec<f64>,
784    /// The prior sd of each coefficient the fit ended with (ARD's learned).
785    pub sd: Vec<f64>,
786    /// The spread of the session, lesson and judge effects.
787    pub spread: [f64; FAMILIES],
788    /// Each level's effect, (mean, marginal sd).
789    pub sessions: BTreeMap<String, (f64, f64)>,
790    pub lessons: BTreeMap<String, (f64, f64)>,
791    pub judges: BTreeMap<String, (f64, f64)>,
792    /// The judge of the newest labelled check: the one that will judge the next.
793    #[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    /// Whether the model has this build's layout (`P` coefficients). A cache written by a build with
801    /// another layout holds one that is not: its Cholesky rows would be read at the wrong places, so
802    /// decisions and the ledger take it for no model (the fixed rule) until the next fit.
803    #[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/// What a decision draws from, fitted at `borg learn` (select/summary.rs `refit`), never in a hook. The
810/// default is the prior: no data, every decision the fixed rule's.
811#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
812pub struct Fit {
813    /// The outcomes it was fitted on (select/summary.rs `Summary::key`: byte offset and hash), logged
814    /// with each check (`Log::fit`) so a replay finds the same fit. Empty for the prior.
815    #[serde(default, skip_serializing_if = "String::is_empty")]
816    pub key: String,
817    /// The value model; None without a label.
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub model: Option<Model>,
820    /// The forgetting's half-life in sessions (None: none).
821    #[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    /// The engineer's budget (pool tokens per session) in force at the fit, and its price per token
828    /// (`price`); 0 without a budget.
829    #[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    /// Whether ask's or not-now's effect changes sign when an unclear verdict counts as "no change"
834    /// instead of missing: the label pipeline's unclear share decides the sign, so read with care.
835    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
836    pub sensitive: bool,
837    /// Labels the model read, and labels `FIT_SESSIONS` left out.
838    #[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
848/// The age weight of a label `age` sessions old under half-life `h`.
849pub(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
853/// The labelled applies of a summary as the value model's rows, with what the missingness model and
854/// the monitor need beside them.
855struct Data {
856    obs: Vec<Obs<P, FAMILIES>>,
857    /// Per row: its session's tick, its weight before forgetting, and whether its verdict was unclear
858    /// (in the rows only for the unclear-is-no-change variant).
859    tick: Vec<u32>,
860    base_w: Vec<f64>,
861    unclear: Vec<bool>,
862    levels: [usize; FAMILIES],
863    names: [Vec<String>; FAMILIES],
864    /// Per apply of the summary: its levels, its judged probability.
865    lv_of: HashMap<usize, [Option<usize>; FAMILIES]>,
866    judged_p: HashMap<usize, f64>,
867    dropped: u32,
868}
869
870/// The index of a level among the labelled ones, added when new: `names[f]`, `index[f]` by family.
871fn 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
879/// The route a labelled apply counts as in the model: ask, free, or not now (a placebo); None for an
880/// apply never delivered or never judged.
881fn 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
889/// The applies of a logged check of `s` (none for a range the summary does not hold).
890fn 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
894/// The lessons a logged check of `s` could not hand over free (select/summary.rs `Apply::nofree`, its
895/// record's `nofree`): the `had` its decision read, so a replay values it against the same fixed rule.
896fn 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
900/// The units, features and routes of a logged check of `s`.
901fn 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    /// The rows of `s` (the newest `FIT_SESSIONS` sessions), unclear verdicts included as no change
912    /// when `zero`, else left out.
913    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                    // The point never came up: nothing in the work changed on it, whatever the route.
947                    Label::Ignored | Label::NotArisen => 0.0,
948                    // A change that left the consequence is read as an unclear verdict.
949                    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        // Every apply's levels among the labelled ones (the monitor's predictions): an unseen one has none.
971        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    /// The rows without their session, lesson and judge levels (P2's pruned model, `fit_pruned`).
984    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    /// The rows weighted by forgetting at `now` under `h`.
997    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
1002/// Per delivered apply of `s` with a closed fate (judged, unclear or censored), the probability that
1003/// the judge gave it a verdict: a logistic fit on its route, channel, the session's applies waiting
1004/// for a verdict at its check, and whether its check was the session's last. Unclear (and a remaining
1005/// consequence, `Label::pending`) counts as not judged; not arisen as judged (`Label::verdict`).
1006pub(crate) fn judged(s: &Summary) -> HashMap<usize, f64> {
1007    // Derived: the fit's features (intercept, asked, free, stop, log pending, last check).
1008    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
1041/// The log-likelihood of `obs` under `est`, each row at weight `w`.
1042fn loglik(est: &Est<P, FAMILIES>, obs: &[Obs<P, FAMILIES>]) -> f64 {
1043    obs.iter()
1044        .map(|o| {
1045            // Clamped so a certain miss costs a large finite loss, not −∞.
1046            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
1052/// The forgetting's half-life (`HALF_LIVES`) with the highest prequential log-likelihood: each of the
1053/// newest `PREQ_SESSIONS` labelled sessions predicted by a fit on the sessions before it, aged from it;
1054/// the spreads stay at `base`'s. A tie goes to the longer half-life.
1055fn 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        // A tie (within float noise, relative 1e-9) keeps the longer half-life found first.
1082        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
1089/// The value model of `est` with the level names of `d`; None unless every number is finite (a JSON
1090/// cache cannot hold another, select/summary.rs `save`).
1091fn 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/// The fit of `s` (module doc): the value model, the forgetting, the delivery rates, the pool's state
1114/// and, under an engineer's `budget` (pool tokens per session), its price. The prior without labels.
1115#[must_use]
1116pub fn fit(s: &Summary, budget: Option<f64>) -> Fit {
1117    fit_with(s, budget, &coefs(), true)
1118}
1119
1120/// `borg debug select` P2's pre-registered pruned model (tasks/todo.md, P2: "prune the model to
1121/// grade, route and dilution and rerun once"): `fit` of `s` with the value model cut to the terms of
1122/// `PRUNED_KEEPS`, every other coefficient held at N(0, `SD_FLOOR`²) and no session, lesson or judge
1123/// effects (their spreads 0, so a prediction adds none), its half-life chosen as `fit` chooses it.
1124/// Evaluation only: never what a hook decides with.
1125#[must_use]
1126pub fn fit_pruned(s: &Summary, budget: Option<f64>) -> Fit {
1127    fit_with(s, budget, &pruned(), false)
1128}
1129
1130/// `fit` under the priors `spec`, with the session, lesson and judge effects when `levels`.
1131fn 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            // Without families no level has an effect: the fit's spreads are EM's untouched start.
1154            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/// The n = 0 prior as a fit, for `borg debug select` P4's "ungated policy under the n = 0 prior": the
1166/// value model's MAP over no labels (every coefficient at its prior mean with its prior covariance,
1167/// every family at its starting spread) and the delivery rates' Beta(1, 1) means. Never what a hook
1168/// decides with: `fit` without a label has no model, so cold start is the fixed rule.
1169#[must_use]
1170pub fn prior() -> Fit {
1171    prior_with(&coefs(), true)
1172}
1173
1174/// The n = 0 prior of P2's pruned model (`fit_pruned`), for its rerun's units with no label before
1175/// their check: `prior` under the pruned model's priors (`pruned`) and, as `fit_pruned`, no session,
1176/// lesson or judge effects (spreads 0). Evaluation only.
1177#[must_use]
1178pub fn prior_pruned() -> Fit {
1179    prior_with(&pruned(), false)
1180}
1181
1182/// The value model's MAP over no labels under the priors `spec`, each family at its starting spread
1183/// when `levels`, else at 0 (`fit_with`), and the delivery rates' Beta(1, 1) means.
1184fn 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
1194/// π/8, the probit approximation's scale: E[σ(η)] ≈ σ(μ / √(1 + π σ² / 8)) for η ~ N(μ, σ²) (MacKay
1195/// 1992). Method.
1196const PROBIT: f64 = std::f64::consts::PI / 8.0;
1197
1198/// The model's chance that an apply with features `f`, routed `route` among `m` of its route, changes
1199/// the work (`borg debug select` P2's prediction): σ(η) averaged over η's Laplace posterior by the
1200/// probit approximation (`PROBIT`), η's variance from the coefficients' covariance and the session's,
1201/// lesson's and next judge's effects (an unseen level at 0 with its family's spread). None without a
1202/// model of this build's layout.
1203#[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    // (Lᵀx)_k over the lower factor's rows i ≥ k: xᵀΣx = |Lᵀx|².
1208    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
1223/// The delivery rates of `s` under half-life `h`: Beta(1, 1) on every session's tallies, the compacted
1224/// ones included (select/summary.rs `Summary::tallies`); a live question's certain until one is tallied.
1225fn 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    // Baseline before data (`Rates::ask_live`).
1229    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
1233/// The pool's learned state from `s` into `pool`: the yields (PAVA over the bins), tokens per lesson,
1234/// and the latency model (least squares plus the residuals' 95% quantile).
1235fn pool_state(s: &Summary, pool: &mut PoolFit) {
1236    // Up to the first bin no lesson was shown in: past it (a gap between a tail sample's sparse places,
1237    // select/summary.rs `tail_of`) a bin's yield would be its prior alone, and a pool reaching there
1238    // would count as known (`pool_feasible`). A pool's own bins have no gap.
1239    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
1262/// Pool adjacent violators: Beta posteriors per bin, merged so their means never rise with position;
1263/// each bin of a merged block carries the block's summed parameters.
1264fn 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/// A joint draw of the model for one check: the coefficients, and each unit's η without its route
1283/// (its session, lesson and judge effects included).
1284#[derive(Debug, Clone)]
1285struct Theta {
1286    b: [f64; P],
1287    base: Vec<f64>,
1288}
1289
1290/// The seed of each level of a check, by family and name, so draw k of one session or lesson is the
1291/// same at every check (the gate's ledger re-values past checks with the same draws).
1292struct 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
1310/// Draw `seed` of `m`'s coefficients: β̂ + L z, coordinate by coordinate, each bounded slope drawn
1311/// from its normal given the coordinates before it truncated at 0 (`below_zero`: the same z mapped by
1312/// its inverse CDF, never a clamp, which would put half the mass of a slope whose mode is 0 on 0 itself)
1313/// and the later coordinates conditioned on the value it took. Under the n = 0 prior (the slopes
1314/// independent of the rest) each is exactly its prior, `DILUTION_PRIOR`'s half-normal; with data it is
1315/// the sequential (GHK) approximation of the truncated joint posterior.
1316fn 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            // The z that gives the truncated value: what the later coordinates are conditioned on.
1328            if l(i, i) > 0.0 {
1329                z[i] = (x[i] - given) / l(i, i);
1330            }
1331        }
1332    }
1333    x
1334}
1335
1336/// The draw of N(`mean`, `sd`²) truncated to ≤ 0 at the standard normal draw `z`: Φ⁻¹ of Φ(z) scaled
1337/// into the mass below 0, in logs so a bound far below the mean neither underflows nor leaves the
1338/// half-line. Monotone in z; `mean` held ≤ 0 when `sd` is 0.
1339fn 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    // The draw's lower tail Φ(z)·Φ(bound) and its upper 1 − that, each without cancellation.
1345    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
1350/// The standard normal CDF Φ(x): O(1), relative error below 1.2e-7 (`erfc`).
1351fn phi(x: f64) -> f64 {
1352    0.5 * erfc(-x / std::f64::consts::SQRT_2)
1353}
1354
1355/// ln Φ(x), finite far into the lower tail (`erfcx`, the factor exp(−x²/2) taken out).
1356fn 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
1360/// erfc(x) (`erfcx` for x ≥ 0, reflected below).
1361fn 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
1367/// exp(x²)·erfc(x) for x ≥ 0: the Chebyshev fit of Numerical Recipes' `erfcc` (Press et al. 2007,
1368/// §6.2), its relative error below 1.2e-7 everywhere. Method: the coefficients are the fit's.
1369fn 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
1376/// Φ⁻¹ of the probability whose lower tail is exp(`ln_lower`) and upper tail `upper` (both given, so
1377/// neither tail loses its digits): Acklam's rational approximation, relative error below 1.15e-9.
1378/// Method: the coefficients and the tails' breakpoint (0.02425) are Acklam's.
1379fn 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    // The tail from its log: q = √(−2 ln p).
1389    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
1405/// Draw `seed` of `m` for a check, its coefficients `b` (`draw_beta` of the same seed): each level
1406/// from its marginal (an unseen level from its family's spread), seeded by the draw and the level.
1407/// The levels are drawn apart from β: wider than the joint posterior where a level and the intercept
1408/// trade off, so the gate errs cautious.
1409fn 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
1413/// `m` for a check at its posterior centre (`choose`'s evidence rule): the coefficients at β̂, the
1414/// Laplace posterior's mean, which for a bounded slope is its mode (held ≤ 0; at the n = 0 prior 0,
1415/// not the half-normal's mean −0.08: dilution is not assumed, docs/evidence.md Rules v2), and each
1416/// level at its mean (an unseen one at 0). Not a draw: the draws (`draw_beta`) carry the truncated
1417/// spread the quantile is taken over.
1418fn 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
1427/// `m` for a check under the coefficients `b`, each level's effect from `level` (its posterior mean
1428/// and sd, an unseen level's (0, its family's spread), and its seed in `lv`).
1429fn 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
1441/// Draw `seed` of `m` for a check (`draw_beta`, `theta_with`).
1442fn 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
1446/// The value of each route of each unit under one draw (module doc: τ).
1447struct 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    /// P(changed) of unit j not delivered.
1463    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        // A stop's question blocks, so it arrives; a live one of a check with a 3 only if it reaches the
1476        // drone, during the turn or waiting for the stop, which asks it; one of a check without a 3 no
1477        // stop asks (hook.rs `live_check`): like a hand-over it arrives only at a tool call or the next
1478        // prompt, at the free rate.
1479        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    /// The check's value under `routes` (aligned with the units). Not now (a placebo too) is worth 0,
1493    /// at the stop and during the turn (the comparison is against not delivering): a lesson left for
1494    /// later earns its value at the check that delivers it, whose ledger entry credits it; credited
1495    /// here as well, the later rate × a hand-over's lift, it would count twice, at a rate no draw
1496    /// varies.
1497    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
1512/// A stop's grade 3 is always asked: never routed away from a question without a pre-registered
1513/// randomised test of its own.
1514fn forced(f: &Feat) -> bool {
1515    f.stop && f.grade == 3
1516}
1517
1518/// One draw's value of every unit on every route and size (`Valuer`), computed once: the solver and
1519/// the gate read sums of it, never a sigmoid again.
1520struct Table {
1521    n: usize,
1522    /// `ask[m·n + j]`, `free[m·n + j]` for m in 0..=n (0 unused); not now is worth 0 (`Valuer::value`).
1523    /// Whether j is forced, and whether it may not go free (a lesson the session already had,
1524    /// `nofree_of`).
1525    ask: Vec<f64>,
1526    free: Vec<f64>,
1527    forced: Vec<bool>,
1528    nofree: Vec<bool>,
1529}
1530
1531impl Table {
1532    /// The table of `v`, the units marked in `nofree` (aligned, or shorter: the rest may go free) kept
1533    /// from the free route.
1534    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    /// The check's value under `routes` (`Valuer::value`), read from the table: the tests' check that
1548    /// the solver's entries value as the model does.
1549    #[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/// An f64 ordered by `total_cmp`, for a heap.
1567#[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
1584/// Into `out`: the sum of the `k` largest of the first s values, for every s (NEG_INFINITY while
1585/// s < k). O(n log k).
1586fn 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
1608/// The routes maximising the check's value (`Table::value`) over every routing with at most `rail`
1609/// asks (more only for the forced ones) and `cap` free lessons, the forced units asked and the units
1610/// that may not go free (`Table::nofree`) never free. Exact: for fixed sizes (m_Q, m_F) an optimum
1611/// asks before it hands over in the order of ask's gain over free's (an exchange argument; a unit that
1612/// may not go free has an infinite key, so it leads, and the split never passes it to the free side),
1613/// so per size pair one sort and one sweep over the split. O(n³ log n). Deterministic: ties keep the
1614/// first found (fewer asks, fewer free, earlier split).
1615fn 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    // The open units that may not go free: first in every order below, so every split leaves them on
1620    // the ask-or-not side.
1621    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    // Gains against not now, which is worth 0 (`Valuer::value`), so a route's value; size 0 gains nothing.
1625    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/// The fixed rule's routes for a session that already had `had` (conscience.rs `graded_without`
1680/// without a cap), by position: a lesson the model named twice keeps each copy's route.
1681#[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
1696/// Per unit, whether its lesson is one the session already had (`had`): it may not go free.
1697fn nofree_of(units: &[Unit], had: &[String]) -> Vec<bool> {
1698    units.iter().map(|u| had.iter().any(|h| h == u.lesson)).collect()
1699}
1700
1701/// The check a decision is made for, as the model sees it.
1702#[derive(Debug, Clone, Copy)]
1703pub struct Check<'a> {
1704    pub session: &'a str,
1705    pub stop: bool,
1706    /// The check's pool tokens (0 unknown: the baseline's).
1707    pub tokens: u32,
1708}
1709
1710/// `Decision::source` values. Protocol: logged as the graded record's `source` (outcome.rs), which the
1711/// summary fold, `borg debug select`, `borg top` and the web's Selection view read back.
1712/// `FIXED`: the fixed rule because the policy cannot decide yet (cold start, an empty gate, no model).
1713pub const FIXED: &str = "fixed";
1714/// `SIZE`: the fixed rule past `SOLVE_MAX` applies.
1715pub const SIZE: &str = "size";
1716/// `GATE`: the gate kept the fixed rule, its allowance too short for the deviation's risk (the
1717/// evidence rule's, or the randomised test's).
1718pub const GATE: &str = "gate";
1719/// `EVIDENCE`: the evidence rule's routes (`evident`): the routes best under the posterior mean where
1720/// they beat the fixed rule at the `DELTA` quantile over the `DRAWS` joint draws, else, when the
1721/// randomised test does not run (the stop, no lesson to test, α 0), the fixed rule's.
1722pub const EVIDENCE: &str = "evidence";
1723/// `EXPERIMENT`: a check of the randomised question-count test whose draw s routes differently from
1724/// the fixed rule (`Decision::xk`, `xs`).
1725pub const EXPERIMENT: &str = "experiment";
1726/// `EXPERIMENT_CONTROL`: a check of the test whose draw s is the fixed rule's own count (s = 0
1727/// without a 3, s = k with one), so it routes as the fixed rule: the test's control arm.
1728pub const EXPERIMENT_CONTROL: &str = "experiment_control";
1729
1730/// A check's decision: a route per unit, each unit's chance of being asked (`p`) and handed over free
1731/// (`pf`), what set the routes (`source`: `FIXED`, `SIZE`, `GATE`, `EVIDENCE`, `EXPERIMENT`,
1732/// `EXPERIMENT_CONTROL`), the quality the gate may still risk
1733/// after the ledger's past (`Gate`), at its `DELTA` quantile, and how much of it this decision takes
1734/// (`risk`: how far its routes lower that quantile, 0 for the fixed rule's). The gate admits a
1735/// deviation exactly when `risk` ≤ `gate`, which `borg debug select` P3 checks. A check the randomised
1736/// question-count test considered (sources `EXPERIMENT`, `EXPERIMENT_CONTROL`, and `GATE` when the
1737/// gate refused its draw) carries `xk`, the fixed rule's delivered non-3 lessons that may go free, and
1738/// `xs`, the drawn number of them to ask (on `GATE` the draw the gate refused: the routes are the fixed
1739/// rule's); both None on every other check (outcome.rs `xk`, `xs`).
1740#[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
1752/// The `DELTA` quantile of `v` (the ⌈δ·n⌉-th smallest: the lower side, cautious).
1753fn 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/// The quality `gate` may still risk below the fixed rule at `ALPHA`: its allowance plus the ledger's
1762/// `DELTA` quantile, not held at 0 (`remaining_at` is), so a test can see an overspend as a negative.
1763#[cfg(test)]
1764#[must_use]
1765pub(crate) fn remaining(gate: &Gate) -> f64 {
1766    gate.allowance + lower_quantile(&mut gate.gain.clone())
1767}
1768
1769/// `gate`'s allowance at the risk share `alpha` (select.rs `risk_of`): `choose`'s, for the evidence
1770/// rule's deviations and the randomised test's alike.
1771fn allowance_at(gate: &Gate, alpha: f64) -> f64 {
1772    // Derived: the ledger accrues `ALPHA` × the fixed rule's value per check (`add_with`), so its
1773    // allowance scaled by alpha / `ALPHA` is exactly alpha's; 0 stays 0 whatever the ledger holds.
1774    if alpha > 0.0 { gate.allowance * (alpha / ALPHA) } else { 0.0 }
1775}
1776
1777/// What `gate` may still risk below the fixed rule at the risk share `alpha`, never below 0: the gate
1778/// in force (`Decision::gate`), which the monitoring shows.
1779#[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/// The two parts `remaining_at` adds: `gate`'s allowance at the risk share `alpha`, and its ledger's
1786/// `DELTA` quantile (what the policy's deviations did against the fixed rule, ≤ 0 once they cost).
1787/// The monitoring tells a cold start (no allowance yet) from a spent gate (an allowance the ledger's
1788/// quantile used up) by them.
1789#[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
1794/// The seed of the gate's draw `k` under `fit`: shared by every check, so the ledger and a decision
1795/// re-value with the same joint draws.
1796fn gate_seed(fit: &Fit, k: usize) -> u64 {
1797    mix(hash(&[b"gate", fit.key.as_bytes()]), k as u64)
1798}
1799
1800/// A check's joint draws of the model (`gate_seed`: the ledger's, so a decision and its ledger entry
1801/// re-value with the same draws) and the fixed rule's value under each.
1802struct 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    /// Draw `k`'s value of `routes` (`Valuer::value`).
1819    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    /// Per draw, the value of `routes` minus the fixed rule's.
1824    fn gains(&self, routes: &[Route]) -> Vec<f64> {
1825        self.base.iter().enumerate().map(|(k, b)| self.value(k, routes) - b).collect()
1826    }
1827}
1828
1829/// The evidence rule (module doc): the routes best under the posterior mean (`theta_mean`, `solve`),
1830/// when they are not the fixed rule's `base` and gain on it at the `DELTA` quantile over the joint
1831/// draws `d`; else None (the fixed rule). The units in `nofree` never go free.
1832fn 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
1838/// The randomised question-count test's lessons (module doc), by position: the applies the fixed rule
1839/// delivers (`base`: asks or hands over) that are not a 3 and whose lesson the session never had
1840/// (`had`: such a lesson may not go free, so it keeps the fixed rule's route), in the fixed rule's order
1841/// (the higher grade first, then the model's: select.rs `ordered`). Their number is the test's k.
1842pub(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
1849/// The test's routes for the count `s`: `base` with the first `s` of `order` (`tested`) asked and the
1850/// rest of it handed over.
1851fn 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
1859/// The test's count at the check whose seed is `seed`: s uniform in 0..=k, on the test's own stream
1860/// of the seed (apart from the placebo's, select.rs `draw_placebo`). The modulo's bias is below
1861/// (k + 1)/2⁶⁴.
1862fn 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/// The routes of `units` at `check` for a session that already had the lessons `had` (module doc: the
1867/// decision), none of those handed over free (its `pf` 0) and the fixed rule they are gated against
1868/// the same (`fixed_routes` with `had`). Without a model, with an empty gate or past `SOLVE_MAX`
1869/// applies, exactly the fixed rule, its choice as the propensities. The gate's bound holds for
1870/// decisions made one after another, each against a `gate` with every earlier decision folded in
1871/// (`ledger_add_without`, with the same `had`): two checks deciding against the same `gate` could each
1872/// take all it may still risk, so a caller whose checks run at once decides and records each under one
1873/// lock.
1874///
1875/// `alpha` is the share of the fixed rule's value the engineer tolerates risking for learning (select.rs
1876/// `risk_of`, `ALPHA` unless the collective sets `risk`): the gate's allowance in this decision is the
1877/// ledger's (accrued at `ALPHA`) scaled to it, for the evidence rule's deviations and the test's alike.
1878/// The randomised question-count test runs on a live check (`check.stop` false) whose evidence keeps
1879/// the fixed rule (an evidence deviation the gate refuses is not tested either), only while `alpha` is
1880/// above 0, its count drawn from `seed`.
1881#[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    // What `routes` take of the remaining budget, None when the gate refuses them: admitted iff
1907    // allowance + q(gain + their gain) ≥ 0, that is iff `risk` = q(gain) − q(gain + their gain) ≤
1908    // allowance + q(gain).
1909    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    // Every count's routes as played (a count the gate refuses plays the fixed rule's), so each unit's
1937    // propensity is its exact share over the k + 1 equally likely counts.
1938    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/// The decision rule without the randomised question-count test, for a session that already had `had`
1968/// (none of those handed over free, gated against `fixed_routes` with the same `had`): `borg debug
1969/// select` P4a (under the n = 0 prior it must be the fixed rule on every check) and P4b (recall under
1970/// the store's posterior). The evidence rule (`evident`): the routes best under the posterior mean,
1971/// played only when they beat the fixed rule at the `DELTA` quantile over the `DRAWS` joint draws, else
1972/// the fixed rule; `choose`'s routes with α 0 under a gate that admits them. Deterministic: it draws
1973/// nothing of its own (its joint draws are the gate's, `gate_seed`), so it takes no seed. The fixed
1974/// rule whatever the evidence: no model, no applies, past `SOLVE_MAX`.
1975#[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
1987/// Per unit, the share of the equally likely routings `draws` that give it `route`, rounded (outcome.rs
1988/// `round_p`): one routing's 0 or 1, the test's k + 1 counts' exact shares.
1989fn 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
1994/// `gate` with one more check of the learned policy, decided for a session that already had `had`
1995/// (the graded record's `nofree`): per joint draw k, the value of the routes it took (`routes`) minus
1996/// the fixed rule's with the same `had` (the rule `choose` gated it against), and `ALPHA` × the fixed
1997/// rule's value at its draws' mean (≥ 0; `choose` scales it to the collective's α). The prior fit adds
1998/// nothing: the ledger opens with a model.
1999pub 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
2005/// `ledger_add_without` with the gate's coefficient draws (`draw_beta` of `fit`'s model per
2006/// `gate_seed`) drawn once.
2007fn 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    // A placebo is not delivered: as the policy decided, not now.
2017    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/// The gate's ledger over every learned check of `s` under `s.fit` (at `refit`), each as the fold adds
2036/// it (select/summary.rs, `ledger_add_without` with the record's `nofree`, `logged_had`).
2037#[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/// The monitor's reading of the learned policy against the fixed rule (module doc).
2051#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
2052pub struct Monitor {
2053    /// The store went back to the fixed rule (sticky: select/summary.rs `REVERTED`).
2054    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2055    pub reverted: bool,
2056    /// Sessions and applies of the learned policy it read.
2057    #[serde(default, skip_serializing_if = "is_default")]
2058    pub sessions: u32,
2059    #[serde(default, skip_serializing_if = "is_default")]
2060    pub units: u32,
2061    /// Per apply, the mean of (policy − fixed rule) + α × the fixed rule's value, and the confidence
2062    /// sequence's upper bound on it (None before `CS_MIN` sessions): below 0, the policy costs more
2063    /// than α of the fixed rule's quality.
2064    #[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    /// How far its weights can be trusted (borg top `Evaluation`): the labelled applies weighed, the
2069    /// effective number of the weights the estimate used (truncated, normalised per route: (Σw)² /
2070    /// Σw²), the largest of them against their mean, and the applies whose weight the √N cap cut; each
2071    /// over the applies whose weight can move the estimate: a residual in use (`ROUTE_LABELS_MIN`) on a
2072    /// taken route where the policy and the fixed rule differ (a placebo both rules leave undelivered
2073    /// has none, however large its weight).
2074    #[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/// The monitor of `s` (module doc): per session, the doubly robust difference between the policy
2085/// that ran (its logged propensities) and the fixed rule, each apply's outcome model fitted without
2086/// its session's half (cross-fitting by the parity of the session's tick), the inverse-probability
2087/// weights truncated at √N × their mean and normalised per route, a route's weighted residual in use
2088/// from `ROUTE_LABELS_MIN` labelled applies on (the direct-method term alone before); plus α × the
2089/// fixed rule's value;
2090/// per apply, a session's mean an increment, in session order, under a one-sided time-uniform
2091/// confidence sequence at `DELTA` (`upper_bound`). Trips when its upper bound falls below 0.
2092#[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    // Per learned apply: the session, the per-route difference terms before the weights.
2107    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        // The fixed rule for the lessons the session had, as the decision was gated against it.
2121        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            // The held-out session's own effect is not in its fold's fit.
2128            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            // Floors: a logged ψ or μ of 0 for a route that happened is rounding (outcome.rs `round_p`),
2143            // so the least the log can say (ψ at 1/1000, μ at one draw of `DRAWS`).
2144            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    // A route's residual only from `ROUTE_LABELS_MIN` labels with leverage on: below, its direct-method
2163    // term alone. A label whose difference is 0 adds nothing to the residual, only weight to the
2164    // route's normaliser, so it opens no route.
2165    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    // The weights' health, over the labelled applies' weights as the estimate used them (each > 0), of
2183    // the applies whose weighted residual enters it: a residual in use, times a difference that is not
2184    // 0 (a placebo neither rule delivers moves nothing, its weight however large).
2185    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
2203/// The time-uniform upper bound on the running mean of `x` at `DELTA`, intersected over time from
2204/// `CS_MIN` on: the normal-mixture boundary on the sum, √((v + ρ)(ln(1 + v/ρ) + 2 ln(1/δ))) with
2205/// v = t·σ̂² (Howard et al. 2021; asymptotically valid with the variance plugged in, Waudby-Smith et al.
2206/// 2024), ρ tightest at `CS_SESSIONS`; the two-sided boundary read on one side, so cautious. Valid at
2207/// every time at once, so reading it after every session costs no error. +∞ before `CS_MIN`. An
2208/// empirical-Bernstein sequence would be valid without asymptotics but needs a range bound, and the
2209/// truncated weights' is far wider than the increments' spread: it could never trip on a few dozen
2210/// sessions.
2211fn 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/// The seed of a session's pool draw (`pool_tokens`).
2232#[must_use]
2233pub fn session_seed(session: &str) -> u64 {
2234    hash(&[b"pool", session.as_bytes()])
2235}
2236
2237/// The pool budgets (`POOL_STEPS`) in tokens.
2238#[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
2243/// The quality a pool of `tokens` buys per check under one draw (`seed`), its price aside: the
2244/// expected applies of its fill positions times what an apply is worth there (the grader's dilution
2245/// included).
2246fn 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
2261/// Whether a pool of `tokens` may be chosen at all: never above the baseline without a known latency
2262/// limit (`limit_ms`) its 95% latency fits in, latency observed at that budget (`LATENCY_NEAR` of it,
2263/// `PoolFit::latency_reach`: the fit read among its calls, never extrapolated) and a yield for every
2264/// position it adds; below it, a yield for every position it drops, or a price to pay.
2265fn 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
2281/// The pool's draws under a fit: per budget of `pool_grid`, its value under each of the `DRAWS`
2282/// shared draws (the gate's evidence) and under a session's own draw (its play).
2283struct 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    /// The budget a session plays (`play`: its own draw's value per budget) at price `lambda`: of the
2296    /// feasible budgets, those whose gain over the baseline, price included, is ≥ 0 at the `DELTA`
2297    /// quantile of the shared draws (the baseline always; a larger budget only on a gain > 0 there,
2298    /// never on draws that tie with the baseline), the one its draw values most.
2299    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    /// A session's own draw of each budget's value (`seed`: `session_seed`).
2316    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/// The pool budget for a session (`seed`) under `fit`: the baseline (whole.rs `TOKENS`) unless the
2322/// learned policy is in force (`active`); else, of the feasible budgets of `pool_grid` whose gain over
2323/// the baseline, the budget's price included, is ≥ 0 at the `DELTA` quantile (> 0 for a larger one,
2324/// `PoolDraws::choose`; the gate's caution: an
2325/// unidentified dilution never moves the pool), the one the session's Thompson draw values most. One
2326/// draw per session keeps a session's pool, and its prompt prefix, stable.
2327#[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/// The budget's price per pool token: the smallest λ ≥ 0 under which the pools `pool_tokens` chooses
2339/// for the newest `FIT_SESSIONS` sessions of `s` (each with its own draw, every check at its
2340/// session's pool) spend at most `budget` tokens per session on average, by bisection (a higher price
2341/// never grows a pool); deterministic. The pools are replayed under the loosest latency limit, where
2342/// every budget the latency evidence covers may be chosen: a hook's limit (live.rs `call_limit`) only
2343/// takes the largest budgets out of the choice, and a choice among fewer, smaller budgets never takes
2344/// a larger pool, so what the hooks spend under any limit stays within the spend replayed here. A
2345/// budget no pool meets gets the price at which every session takes the smallest pool.
2346#[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        // The loosest limit: none binds (u64::MAX ms).
2356        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    // The price at which even the most valuable apply is not worth one token more: every pool shrinks.
2363    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    /// Every grade sequence over {1, 2, 3} of up to `n` applies.
2389    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    /// A planted world: the judge's P(changed) for an apply on a route among m, and a lift of the stop's
2406    /// checks with a 3 on the log-odds scale, planted apart from the model's terms.
2407    #[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    /// A simulated outcome log: per session, checks of random grades routed by `route` (given the
2420    /// grades, the routes and their logged p, pf), delivered, judged from `truth` (a placebo too), and
2421    /// settled at the session's end.
2422    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    /// Each apply's route drawn uniformly over ask, free and placebo, the stop's 3s asked.
2540    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    /// The fixed rule's routes and propensities.
2551    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        // Rank has no effect in this world: relevance shrinks its slope's prior.
2591        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    /// The fixed rule's history (every 3 asked with its block, every check without one handed over)
2597    /// in a world without dilution: the labels say nothing about it, the dilution slopes keep their
2598    /// dated prior's sd, while EM still moves the terms without one.
2599    #[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    /// The pruned model's n = 0 prior, what P2's rerun predicts a unit with no label before its check by:
2615    /// the kept terms at their prior, every other at 0 with the floor's sd, and no level's spread (an
2616    /// unseen session or lesson adds no variance), unlike the full model's prior.
2617    #[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        // Not now, whose prior mean is off 0 (`NOT_PRIOR`): at a mean of 0 every variance predicts ½.
2629        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    /// P2's pruned model: grade, route and dilution learned, every other term held at 0 with the
2635    /// floor's sd, no session, lesson or judge effect (a prediction is the same for any of them), the
2636    /// half-life chosen as the full fit's is. The full fit of the same world learns the rank slope and
2637    /// the stop's effect the prune leaves out.
2638    #[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    /// The same model two ways: four session effects as a random-effect family (eliminated by its
2671    /// Schur complement), and as four fixed coefficients with the family's prior. Their MAP and
2672    /// covariance agree.
2673    #[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    /// Every feasible routing's value, the best; a unit marked in `nofree` never free.
2732    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            // Every other case: units that may not go free (lessons the session had), forced ones too.
2772            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        // During the turn a question arrives at the learned rate a live one reached the drone.
2798        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        // Not now counts nothing during the turn, whatever the later rate: the check that delivers it
2801        // later credits its value, and its ledger entry with it.
2802        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        // The lift of asking a 2, which the check's 3 does not change.
2829        let lift = sigmoid(0.25 + b[ASK]) - sigmoid(0.25 + b[NOT]);
2830        assert!(lift.abs() > 0.01);
2831        // On a check with a 3 the question waits for the stop, which asks it.
2832        assert!((ask(true) - 0.9 * lift).abs() < 1e-12);
2833        // Without one no stop asks it (hook.rs `live_check`): a tool call or the next prompt brings it,
2834        // as it brings a hand-over.
2835        assert!((ask(false) - 0.3 * lift).abs() < 1e-12);
2836    }
2837
2838    /// A gate that may risk plenty: the learned policy decides.
2839    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    /// A decision is a function of its inputs; the seed moves only the randomised test's count, so at
2887    /// the stop, where the test never runs, no seed changes it.
2888    #[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    /// A model that thinks handing over beats asking by a wide, uncertain margin.
2931    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    /// Guard: a stop's grade 3 is asked whatever the model says. The same 3 during the turn goes free,
2942    /// so the guard, not the model, keeps it asked.
2943    #[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    /// A lesson the session already had is never handed over free, at cold start (the fixed rule
2957    /// `graded_without`) or by the learned play, its `pf` 0, though the same model hands it over
2958    /// without `had`. Asking it stays open.
2959    #[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        // A stop's 3 the session had is still asked.
2977        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    /// Guard: a check with more applies than `SOLVE_MAX` takes the fixed rule (source `size`). The same
2983    /// model and gate deviate at `SOLVE_MAX` applies, so the guard, not the model, keeps the fixed rule.
2984    #[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        // A 3 and 2s: the fixed rule asks them all.
2990        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    /// A history where every 3 was asked (the fixed rule): the model never saw a 3 handed over, and a
3002    /// stop's 3 stays asked.
3003    #[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    /// The fixed rule's history logged as the learned policy's cold start (its gate opens on it), in a
3020    /// world with no route effect and the stop's checks with a 3 lifted by `lift`.
3021    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    /// The fixed rule asks exactly when a check has a 3, so a lift of the stop's checks with a 3 and
3038    /// asking at the stop are one cell of its history. The lift goes to its non-route twin
3039    /// (`three_stop`), not to asking at the stop: a stop without a 3 is asked as often as in the same
3040    /// history without the lift (on evidence alone, so not at all), never by a learned certainty.
3041    #[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    /// The gate's invariant over a run of checks: the replayed value of what the policy did, at the
3070    /// `DELTA` quantile, never falls more than α × the fixed rule's replayed value below the fixed
3071    /// rule. The same run ungated (every draw of the test admitted) breaks it, so the gate, not the
3072    /// scenario, holds it.
3073    #[test]
3074    fn the_gate_never_risks_more_than_its_alpha_share() {
3075        // A wide posterior whose mean says asking is a little worse than handing over: no evidence for
3076        // a deviation, and most of the test's asks lose under the posterior.
3077        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                    // A deviation is admitted exactly when its risk fits what the gate may still risk; a
3104                    // refused one keeps the fixed rule and says so.
3105                    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        // Pool evidence: every check showed 40 lessons for 12k tokens and applied the first few.
3138        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    /// The hooks choose pools under their harness's limit (live.rs `call_limit`), where a pool with
3157    /// latency evidence may grow past the baseline. The budget's price holds what they spend within the
3158    /// budget under every limit: no limit, one only the middle growth fits under, room for all.
3159    #[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        // Yields known past the largest budget, latency evidence enough, no dilution: unpriced, every
3166        // pool grows as far as the limit lets it. The 95% latency at 16,971 tokens is 3,197 ms, at
3167        // 24,000 3,900 ms.
3168        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        // High yields everywhere, but no latency evidence: never past the baseline.
3204        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    /// A larger pool needs a gain > 0 at the `DELTA` quantile: draws that tie with the baseline (no
3209    /// yield past it to tell them apart) never move it, whatever a session's own draw prefers. A
3210    /// smaller one still may on a tie (it costs nothing it was not already pricing).
3211    #[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        // The baseline and the larger budgets (a smaller one would win the tie of plays at 0).
3219        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    /// A pool grows only to a budget its latency was measured at: a flat fit over calls at the
3231    /// baseline (a team store on 2026-10-31: b = 0, 203 calls, none above 12,000 tokens) admits no
3232    /// larger budget however roomy the limit, and calls that read `LATENCY_NEAR` of a budget admit it.
3233    #[test]
3234    fn a_pool_grows_only_where_its_latency_was_observed() {
3235        let mut s = Summary::default();
3236        // Pairs of calls at the same size, one 16 s and one 19 s: no slope at all.
3237        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        // Yields known past the largest budget, as the bins would give them (`pool_state` reads them).
3239        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        // Twenty calls that read 15,300 tokens or more: 16,971 is within reach, 24,000 is not.
3252        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        // The same evidence without the reach (a fit from before it): no growth.
3257        assert!(!pool_feasible(&PoolFit { latency_reach: 0, ..pool }, 16_971, roomy, 0.0));
3258    }
3259
3260    /// A tail sample's sparse places can leave a bin no lesson was shown in before bins that have some:
3261    /// the yields stop at it, so its prior alone never counts as a yield and no pool past it is known.
3262    #[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    /// The confidence sequence: rarely below the true mean at any time, and it closes in on it.
3300    #[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    /// Sixty sessions of a learned policy that hands over half of what the fixed rule asks: under a
3315    /// truth where asking changes far more than handing over (`free_helps` false) the monitor trips;
3316    /// where both routes are as good it does not.
3317    fn monitored(free_helps: bool) -> Summary {
3318        summary::with_fit(monitored_sim(free_helps).0.summary(), None)
3319    }
3320
3321    /// `monitored`'s log before the fit, and its truth.
3322    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        // Every check has a 3 (the fixed rule asks all), live.
3334        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    /// A placebo neither rule delivers (both leave it not now) has no leverage: its weight, however
3348    /// large, multiplies a difference of 0. Nor has a free lesson the policy hands over surely, as the
3349    /// fixed rule does. The monitor's estimate is the 0.7.2 one (pinned within 1e-9), and its weights'
3350    /// health leaves those applies out: before, the placebo, its route's only label, counted at the
3351    /// whole route's weight (a team store's ESS 9 of 127, largest weight 42×).
3352    #[test]
3353    fn a_placebo_neither_rule_delivers_moves_neither_the_estimate_nor_its_health() {
3354        // The monitor of this log under 0.7.2, before its health left zero-leverage applies out.
3355        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        // One check of 12 grade-1 lessons routed as the fixed rule routes them (`CAP` handed over
3360        // surely, the rest not now), the last of the rest drawn as a placebo and judged.
3361        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        // The health of the applies that move it: the check added none.
3383        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    /// A placebo on a check where the policy deviated (the fixed rule asks it, the policy left it not
3388    /// now with chance 1/2) has leverage, and as its route's only label its normalised weight is the
3389    /// whole route's: under `ROUTE_LABELS_MIN` its residual stays out, so one verdict moves the
3390    /// estimate only as far as it moves the fit (0.7.2: by 0.37 of a unit).
3391    #[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    /// `ROUTE_LABELS_MIN` counts labels with leverage: judged placebos neither rule delivers (the
3410    /// expected kind) do not open the not-now route, so the first placebo on a deviated check after
3411    /// them still moves the estimate only as far as it moves the fit.
3412    #[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        // Its weights' health (borg top `Evaluation`): labelled applies weighed, an effective number no
3455        // larger, the largest weight at least their mean, no more cut than weighed.
3456        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    /// `choose` at cold start is `fixed_routes` (`cold_start_is_the_fixed_rule`); this pins
3465    /// `fixed_routes` itself, by position.
3466    #[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        // The same lesson named twice past the cap: each copy keeps its own route.
3479        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    /// `borg debug select` P4's policy (`decide_on_evidence`) is `choose`'s routes without the test (α
3490    /// 0) under a gate that admits every deviation, whatever `choose`'s seed; without a model it is the
3491    /// fixed rule.
3492    #[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    /// P4a's rule, here on every grade table: under the n = 0 prior the evidence rule is the fixed rule,
3515    /// at the stop and during the turn, for a session that had a lesson or none, and so is `choose`
3516    /// without the test (its source `evidence`, the prior's deviations refused by no gate but by the
3517    /// evidence). A Thompson play of the prior leaves the fixed rule on such tables.
3518    #[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    /// A fit certain that every question dilutes the others (`ask_dilution` −1, tight): asking the nine
3540    /// a check with a 3 asks loses to asking few and handing over the rest, and the evidence rule
3541    /// deviates, exactly (0/1 propensities, no test). A fit whose mode dilutes but whose posterior,
3542    /// truncated at 0 as every draw is (`draw_beta`), still reaches no dilution at the `DELTA` quantile
3543    /// (`ask_dilution` −0.2, sd 0.5), or a fit without dilution, keeps the fixed rule.
3544    #[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            // The wide fit's mode would deviate: only the quantile keeps it on the fixed rule.
3573            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    /// The test's lessons and order for `units` at a live check under the n = 0 prior (no evidence) and
3586    /// a gate that admits every count: `choose` at many seeds.
3587    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    /// The randomised test draws s uniformly in 0..=k (each count's frequency over seeds within four
3594    /// standard errors of 1/(k + 1)), asks the first s in the fixed rule's order (2s before 1s, then
3595    /// the model's) and hands over the rest, its propensities each unit's exact share of the k + 1
3596    /// counts, s = 0 (the fixed rule without a 3) the control.
3597    #[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    /// The test never runs at the stop, and never moves a 3: with a 3 during the turn the 3 is asked on
3623    /// every draw (p 1), and the control is s = k, the fixed rule asking everything.
3624    #[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    /// A lesson the session already had is not among the test's lessons: it keeps the fixed rule's
3646    /// route on every draw (not now without a 3, asked with one), never free.
3647    #[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    /// The gate rations the test: with nothing left to risk, every count that leaves the fixed rule is
3664    /// refused (source `gate`, the fixed rule's routes, k and the refused s logged, nothing taken), so
3665    /// the propensities are the fixed rule's; and α 0 (`risk` 0 in collectives.json) turns it off.
3666    #[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        // The same gate under a larger α (the collective's `risk`) has room: the share scales the
3688        // allowance the ledger accrued at `ALPHA`.
3689        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    /// α scales the evidence rule's room too: a ledger that holds no gains, only a loss it already
3695    /// took, admits an evident deviation at `ALPHA` from the allowance alone, and α 0 (`risk` 0 in
3696    /// collectives.json) refuses the same deviation (source `gate`, the fixed rule's routes, nothing
3697    /// taken), though the ledger's allowance is unchanged.
3698    #[test]
3699    fn risk_zero_refuses_an_evidence_deviation_the_allowance_alone_would_cover() {
3700        let f = free_beats_ask();
3701        // A 3 and 2s during the turn: the fixed rule asks them all, the model hands them over.
3702        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        // Guard: the loss outweighs any gain the deviation's own value can make (a value is a sum of
3707        // per-apply chances, so it gains less than one per apply), and the allowance covers the loss.
3708        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    /// Dilution is not assumed: its prior's mode is 0, so under the n = 0 prior's mean the value of a
3718    /// question does not depend on how many are asked with it.
3719    #[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    /// The gate's and the evidence rule's draws of a bounded slope are its prior truncated at 0, not
3736    /// clamped: under the n = 0 prior `DILUTION_PRIOR`'s half-normal, every draw below 0 (no point mass
3737    /// at 0) with the mean −0.1·√(2/π); the unbounded coefficients keep their plain draw β̂ + L z.
3738    #[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        // Its sd 0.1·√(1 − 2/π) ≈ 0.060 over 4000 draws: a standard error of 0.001.
3746        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        // A bound far above the mean leaves the draw as it was; one far below it, never past 0.
3754        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    /// The n = 0 prior is a model at the priors (means, sds, the families' starting spreads) with
3764    /// Beta(1, 1) delivery rates; `fit` without a label still has none, so cold start stays the fixed rule.
3765    #[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    /// A prediction is σ(η) where the model is certain, drawn toward ½ by its uncertainty (the level
3780    /// effects' included), and nothing for a model of another layout.
3781    #[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}