Skip to main content

borg/select/
replay.rs

1//! `borg debug select [--replay] [--rebuild] [--dir DIR | --file FILE] [--json] [--eval RUNS_DIR --qrels
2//! FILE]`: the selection policy's state and its pre-registered checks, replayed from the outcome log (0
3//! tokens). Reports, never applies.
4//!
5//! **The rules.** docs/evidence.md "Lesson selection" (`RULES_AT`): the pre-registered checks P0–P5,
6//! dated `RULES`; a later change is a dated amendment, and `AMENDMENT` names the latest one this file
7//! implements; `RULES_V2` the rules v2, written for policy v2 (it deviates only on evidence and learns
8//! the question count by a randomised test) with the same bars, P4 split into P4a–P4c. Every report
9//! prints all three.
10//!
11//! **Verdicts.** PASS or FAIL against the bar; NOT RUN when there is nothing to run it on (no
12//! evaluation given, or one still incomplete); NOT TESTED when it ran on data that could not have
13//! failed it (no check that applies a lesson, since a check none applied to and a failed call decide
14//! nothing under any policy; no deviation from the fixed rule; an arm that is the fixed rule
15//! everywhere; no label to learn from; every prediction 1/2), never a pass. The command exits 1 when a
16//! decisive check fails or is NOT TESTED (`unmet`): by the rules v2's default rule, P0–P3, P4a and
17//! P4c, and P4b when it fails (NOT TESTED there means it is the fixed rule); P5 only reports.
18//!
19//! **Two passes over the log, in file order** (the order the hook appended it), each folding it into the
20//! selection summary with select/summary.rs `fold`, so every decision sees only the lines before it,
21//! and each deciding a check under the lessons its session already had, as its record logs them
22//! (outcome.rs `nofree`, `had_of`):
23//! - *as logged*: the log folded as it is. At each graded check the replay re-runs the production
24//!   decision (select.rs `graded_under`) on the summary the hook had: the one its record names
25//!   (outcome.rs `seen`, read before the model call), else every line before the record. P0: with the
26//!   gate closed the learned policy decides as the fixed rule (conscience.rs `graded_without`, no cap),
27//!   which is also the logged decision of every check logged under the fixed rule. P1: the logged
28//!   policy, fit key, summary, revert (outcome.rs `source`), session, channel and check id (its seed)
29//!   give the logged decision, its source, the question-count test's k and s (outcome.rs `xk`, `xs`:
30//!   its draw is the check's seed) and propensities within `ope::AGREE`, and folding piece by piece
31//!   equals folding the whole log.
32//! - *cold start*: the same log, the learned policy deciding from the prior on, each graded record
33//!   replaced by the replayed decision before it is folded (`decided`), so the gate's state is the
34//!   policy's own, and the summary refitted where production refits it (`borg learn` when a session
35//!   ends), so the policy has a model to deviate by. P3: no decision risks more than the gate's budget
36//!   (the question-count test's charged risk among them), the first one is the fixed rule's, and with an
37//!   engineer's budget for the collective the policy's pools stay within it; spend and the decisions'
38//!   sources are reported.
39//!
40//! **Labels** (P2, P5, the readout): a judge's verdict that settles its delivery (`asked_then_changed`,
41//! `asked_ignored`, `asked_not_arisen`, a 0 as the selection model's fit reads it) joined to
42//! the graded check that routed its delivery: by check id, or for older records the session's last
43//! graded check before it that routed the lesson that way (asked for a stop or live verdict, free for a
44//! queued one, placebo for a placebo one). A verdict on an early delivery has no check. A unit is 1 when
45//! its strongest result is the change (`ope::rank`: a later veto or correction in the session outranks
46//! it), 0 otherwise; `asked_not_corrected` is censored, never a 0. What a check could know of a unit is
47//! its value then (`Label::known`): a veto or a stronger verdict after the check is a result from after
48//! it.
49//! - P2 predicts each unit from the labels known before its check (leave-future-out), by the value model
50//!   (select/policy.rs `fit` on exactly those labels at their values then, `predict`; the n = 0 prior's
51//!   before any, `prior`, or the pruned model's `prior_pruned` in the rerun) and by grade alone
52//!   (Beta(1, 1) per grade), and compares their log-loss. After a FAIL, the pre-registered rerun once by
53//!   the model pruned to grade, route and dilution (`fit_pruned`), whose verdict is P2's.
54//! - P5 estimates each policy's change against the fixed rule by doubly robust and inverse propensity
55//!   scoring with the logged propensities (a placebo's times its draw's chance ψ: routed nowhere, a
56//!   unit is judged only when drawn), a Beta(1, 1) per grade and route (leave-future-out) as the
57//!   outcome model; non-decisive.
58//!
59//! **P4** reads an evaluation's graded lines (`#borg-graded` in each run's state files) and the case's
60//! relevant lessons (qrels), and compares the right-lesson recall of the fixed rule with three arms,
61//! each expected over `conscience::P_DRAWS` seeded draws (rules v2):
62//! - P4a: the decision rule without the test (select/policy.rs `decide_on_evidence`) under the n = 0
63//!   prior (`prior`) decides as the fixed rule at every check: 0 differences, else FAIL. It fails if
64//!   any deviation the prior alone drives survives.
65//! - P4b: the same rule under the store's posterior (the whole log fitted as `borg learn` fits it),
66//!   against P4's recall bar; NOT TESTED when it deviates nowhere, which then makes it the fixed rule.
67//! - P4c: the question-count test, ungated (`choose` under the prior with a gate that admits every
68//!   draw), keeps every run's recall at the fixed rule's: it hands over what it does not ask, so it
69//!   withholds nothing. NOT TESTED when no draw leaves the fixed rule (no live check with a lesson that
70//!   may go free).
71//!
72//! **The question-count test's readout** (`test_readout`, rules v2), from the summary's fold of the
73//! log (select/summary.rs `Experiment`, `Check::xk`; never a recount): due after `TEST_CHECKS` checks
74//! that drew a count (played or refused by the gate) in `TEST_MIN_SESSIONS` sessions with one, or
75//! `TEST_SESSIONS` sessions alone (`test_due`, amendment 2026-10-31; pooled over stores, `pooled`), and only
76//! then estimated over the drawn checks the summary still holds (compaction drops the oldest, never
77//! the counts): dilution, the test's top lesson's change rate with m of its questions asked against
78//! asked alone (the same lesson across the uniform draws), and asking a 2 against handing it over (a
79//! Horvitz–Thompson difference by the logged propensities), each verdict weighed by its chance of one,
80//! session-bootstrap 95% intervals.
81//!
82//! **Where it meets the policy core.** Every decision is select.rs `graded_under` with the full `Ctx`
83//! (session, check id, channel, lessons file, summary, logged pool tokens, had, logged tolerance α), the learned policy
84//! named explicitly whatever this process's `policy`; every fold summary.rs `fold`; every pool select.rs
85//! `pool_tokens_under` the learned policy, per check as the hook calls it; the cold start's refits
86//! summary.rs `with_fit`; P0's reference conscience.rs `graded_without`; P2 policy.rs `fit`,
87//! `fit_pruned`, `predict`, `prior`, `prior_pruned`; P3 policy.rs `Decision::risk`; P4 policy.rs
88//! `decide_on_evidence`, `choose`, `prior`, `fit` and its baseline policy.rs `fixed_routes`. Where the
89//! replay still differs from what production ran:
90//! - The refit is `with_fit`, the pure part of the summary.rs `refit` that main.rs `learn_store` calls:
91//!   `refit` loads the cache plus the log's tail (equal to the replay's fold, which P1's fold check
92//!   shows), adds the revert mark beside the store (the replay keeps the revert in memory, sticky as
93//!   `with_fit` keeps it), and writes the cache and the mark, which a replay never does. It runs before
94//!   the first graded record after a session's end, where production runs it in the background after
95//!   the session settles: a hook that decided before `borg learn` finished read the older fit. P1
96//!   replays each record from its logged fit, so only the cold start assumes the refit was in time.
97//! - The had lessons are the record's `nofree`, which is all of `had` a decision reads (the had lessons
98//!   among its applies); a record from before it replays with none, as its hook decided. In the cold
99//!   start they stay the logged history's, not what the replayed policy's own earlier decisions would
100//!   have delivered: only the log's deliveries say that.
101//! - The cold start's labels join by route: where the replayed route differs from the logged one, the
102//!   verdict joins nothing, so its model learns only from the routes the replay took.
103//! - The pools are chosen under the loosest latency limit (`LOOSEST`), as `price` replays them, where
104//!   the hook passes the harness's stop budget: a limit only takes the largest pools out of the
105//!   choice, so P3's pool spend is the most any hook's limit allows.
106//! - P2's fit reads a log rebuilt from the labels (`known`): the graded checks up to the newest result
107//!   then and each label's verdict at its value then, without the delivery records, settles and other
108//!   lines of the real log, so the judge's missingness model sees fewer fates than production's fit at
109//!   that check; no budget (it prices only pools).
110//! - P4 draws its arms at pool tokens 0 (the state files log none), with no had (the state lines log
111//!   no `nofree`, and the hook that ran the evaluation decided without), from a stand-in check id per
112//!   check, each check independent of the run's earlier draws; P4c's gate admits every draw (`OPEN`)
113//!   at the default tolerance (`policy::ALPHA`).
114//! - P1 compares the asked and free lists, propensities and source, not the placebo draw or its chance
115//!   (`psi`): the pre-registered measure.
116
117use std::collections::{BTreeMap, BTreeSet, HashMap};
118use std::path::{Path, PathBuf};
119
120use serde::Serialize;
121
122use super::policy::{self, Fit};
123use super::summary::{self, Summary};
124use super::{Ctx, Policy, Route, Routed, Unit};
125use crate::conscience::{self, Challenge};
126use crate::memory;
127use crate::ope::{self, Estimate};
128use crate::outcome::{self, Outcome};
129use crate::{collective, learn};
130
131/// The pre-registration these checks implement (`RULES_AT`), by the date its rules were written.
132/// Protocol: printed with every report, never changed: a later change is a dated amendment
133/// (`AMENDMENT`).
134pub const RULES: &str = "2026-10-28";
135/// The latest dated amendment of `RULES` this file implements (2026-10-29: P2's prune-and-rerun once;
136/// the bars unchanged). Protocol: printed with every report beside `RULES`.
137pub const AMENDMENT: &str = "2026-10-29";
138/// The rules v2 this file implements, by the date they were written (2026-10-29, after the decisive
139/// rerun: P4a–P4c, P1 and P3 on the question-count test, its readout, the default rule). Protocol:
140/// printed with every report beside `RULES` and `AMENDMENT`.
141pub const RULES_V2: &str = "2026-10-29";
142/// Where the rules and their dated amendments are written: a tracked file of this repository, so a
143/// reader of the report can open them. Protocol: printed with every report.
144pub const RULES_AT: &str = "docs/evidence.md#lesson-selection";
145
146/// P2's bar: the session-bootstrap 95% upper bound of the mean log-loss difference (model − grade
147/// only) may not exceed this. Method: pre-registered.
148pub const CALIBRATION: f64 = 0.005;
149
150/// P4's margin: the policy's right-lesson recall may lie at most this far below the fixed rule's (5
151/// points), point estimate and case-paired bootstrap lower bound. Method: pre-registered.
152pub const RECALL_MARGIN: f64 = 0.05;
153
154/// The online readout is due after this many randomised labelled units (a logged route probability
155/// strictly between 0 and 1) or this many sessions under the learned policy. Method: pre-registered.
156pub const READOUT_UNITS: usize = 300;
157pub const READOUT_SESSIONS: usize = 30;
158
159/// The question-count test's readout (`test_readout`) is due after this many checks that drew a count
160/// (randomised: played or refused) in at least `TEST_MIN_SESSIONS` sessions, or this many sessions with
161/// one. Method: pre-registered (rules v2, 2026-10-29; `TEST_AMENDMENT`).
162pub const TEST_CHECKS: u64 = 300;
163pub const TEST_SESSIONS: u32 = 30;
164/// The sessions with a drawn count `TEST_CHECKS` checks need beside them for the readout to be due.
165/// Method: pre-registered by the amendment of 2026-10-31 (`TEST_AMENDMENT`), written before a pooled
166/// readout was read: sessions are the bootstrap's unit, so 300 checks from a session or two (an
167/// open-source store's: 164 of its 167 in one) would be due with an interval that says nothing.
168pub const TEST_MIN_SESSIONS: u32 = 15;
169/// The dated amendment of rules v2 this file implements (2026-10-31): the readout's sessions
170/// (`TEST_MIN_SESSIONS`), and its pooled reading over several stores, stratified by collective,
171/// intention to treat with the share played per arm (`pooled`). Protocol: printed with the readout.
172pub const TEST_AMENDMENT: &str = "2026-10-31";
173
174/// The normal quantile of a 95% interval: a bootstrap interval read as ±`Z95` standard errors, to
175/// join two collectives' intervals (`pooled`). Method.
176const Z95: f64 = 1.96;
177
178/// How far an arm's recall of a run may lie from the fixed rule's and still be equal (P4c). Method:
179/// float slack of summing `conscience::P_DRAWS` draw shares of 1/`P_DRAWS`, far below any one draw's
180/// share.
181const SAME: f64 = 1e-9;
182
183/// The allowance of the gate P4c's test arm decides against: more than any check's charged risk (a
184/// check's value is a sum of per-lesson chances, at most a few units), so every draw is admitted.
185/// Guard: finite, so the gate's arithmetic stays exact.
186const OPEN: f64 = 1e12;
187
188/// A probability a log-loss reads is kept this far from 0 and 1. Guard: a model's 0 or 1 would make
189/// one unit's log-loss infinite and the mean meaningless.
190const CLAMP: f64 = 1e-9;
191
192/// Differences listed per check. Display.
193const SHOW: usize = 5;
194
195/// The routes a label can have been delivered by, as indices: asked, free, not delivered (placebo).
196/// Protocol: select.rs `ARMS` and `Route::arm`, the order of every per-route array below.
197const ASK: usize = Route::Ask.arm();
198const FREE: usize = Route::Free.arm();
199const NOT: usize = Route::Not.arm();
200const ROUTES: usize = super::ARMS;
201const ROUTE_NAMES: [&str; ROUTES] = ["Q", "F", "D"];
202
203/// Grades index their counts directly, 1–3; 0 is unused. Protocol: the model's grades.
204const GRADES: usize = 4;
205
206/// A check's result against its pre-registered bar.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
208#[serde(rename_all = "snake_case")]
209pub enum Verdict {
210    Pass,
211    Fail,
212    /// Nothing to check it on yet (`why` says what is missing): no evaluation given, or one still
213    /// incomplete. Not decisive: the command passes it.
214    NotRun,
215    /// Run, but on data that could not have failed it: no check that applies a lesson, no deviation
216    /// from the fixed rule, an arm that decides as the fixed rule everywhere, no label learned from,
217    /// every prediction 1/2. Never a pass: a decisive one fails the command (`unmet`).
218    NotTested,
219    /// Reported, not decisive (P5).
220    Reported,
221}
222
223impl Verdict {
224    /// PASS when `ok`, else FAIL; but NOT TESTED instead of a pass when the data could not have failed
225    /// it (`tested` false).
226    fn tested(ok: bool, tested: bool) -> Self {
227        match (ok, tested) {
228            (false, _) => Verdict::Fail,
229            (true, false) => Verdict::NotTested,
230            (true, true) => Verdict::Pass,
231        }
232    }
233
234    fn label(self) -> &'static str {
235        match self {
236            Verdict::Pass => "PASS",
237            Verdict::Fail => "FAIL",
238            Verdict::NotRun => "NOT RUN",
239            Verdict::NotTested => "NOT TESTED",
240            Verdict::Reported => "REPORTED (non-decisive)",
241        }
242    }
243}
244
245// --- The log -------------------------------------------------------------------------------------
246
247/// One line of the outcome log: its text (with its newline) and its record, None when it does not parse.
248struct Line<'t> {
249    text: &'t str,
250    o: Option<Outcome>,
251}
252
253fn lines(text: &str) -> Vec<Line<'_>> {
254    text.split_inclusive('\n').map(|t| Line { text: t, o: serde_json::from_str(t.trim()).ok() }).collect()
255}
256
257/// A graded record's applies, one lesson per apply; None when its grades are not aligned with them.
258fn challenges(o: &Outcome) -> Option<Vec<Challenge>> {
259    (o.grades.len() == o.lessons.len()).then(|| o.lessons.iter().map(|id| Challenge { id: id.clone(), ..Default::default() }).collect())
260}
261
262fn applied<'a>(cs: &'a [Challenge], o: &Outcome) -> Vec<(&'a Challenge, u8)> {
263    cs.iter().zip(o.grades.iter().copied()).collect()
264}
265
266fn ids(v: &[&Challenge]) -> Vec<String> {
267    v.iter().map(|c| c.id.clone()).collect()
268}
269
270fn channel(o: &Outcome) -> &'static str {
271    if o.channel == "live" { "live" } else { "stop" }
272}
273
274/// The lessons a graded record's session already had, as its decision read them (select.rs `Ctx::had`):
275/// its logged `nofree`, sorted, empty for a record from before it (whose hook let every apply go free).
276/// Only the had lessons among a check's applies can change its decision, and `nofree` is exactly those.
277fn had_of(o: &Outcome) -> Vec<String> {
278    let mut had = o.nofree.clone();
279    had.sort_unstable();
280    had.dedup();
281    had
282}
283
284/// The production decision's context for the graded record `o` with its session's `had` (`had_of`).
285fn ctx<'a>(o: &'a Outcome, had: &'a [String], lessons: &'a Path, s: &'a Summary) -> Ctx<'a> {
286    Ctx { session: &o.session, check: &o.check, channel: channel(o), lessons, summary: s, tokens: o.tokens, had, alpha: super::alpha_of(o.alpha) }
287}
288
289/// The policy a graded record can be replayed under: the fixed rule or the learned policy. The
290/// evaluation switches are not: the cap's N is not logged, and the learned cut reads the whole store.
291fn replayable(o: &Outcome) -> Option<Policy> {
292    match outcome::policy_of(o) {
293        "fixed" => Some(Policy::Fixed),
294        "learned" => Some(Policy::Learned),
295        _ => None,
296    }
297}
298
299/// Per apply, 1 when `of` holds its lesson, else 0 (attention.rs `fixed_p`).
300fn holds(lessons: &[String], of: &[String]) -> Vec<f64> {
301    lessons.iter().map(|l| f64::from(u8::from(of.contains(l)))).collect()
302}
303
304/// A graded check of the log: its record, its session's had lessons (`had_of`), its logged decision
305/// and propensities (an older record's free list and pf replayed by `outcome::free_of`), and the fixed
306/// rule's decision on its applies for that session (conscience.rs `graded_without`, no cap).
307#[derive(Debug, Clone)]
308struct Check {
309    o: Outcome,
310    had: Vec<String>,
311    asked: Vec<String>,
312    free: Vec<String>,
313    p: Vec<f64>,
314    pf: Vec<f64>,
315    fixed: (Vec<String>, Vec<String>),
316}
317
318impl Check {
319    fn new(o: Outcome, applied: &[(&Challenge, u8)]) -> Self {
320        let free = outcome::free_of(&o);
321        let p = if o.p.len() == o.lessons.len() { o.p.clone() } else { holds(&o.lessons, &o.chosen) };
322        let pf = if o.pf.len() == o.lessons.len() { o.pf.clone() } else { holds(&o.lessons, &free) };
323        let had = had_of(&o);
324        let (ask, fr) = conscience::graded_without(applied, None, &had);
325        Check { had, asked: o.chosen.clone(), free, p, pf, fixed: (ids(&ask), ids(&fr)), o }
326    }
327
328    fn line(&self) -> String {
329        let check = if self.o.check.is_empty() { "-" } else { self.o.check.as_str() };
330        format!("session {} check {check} ms {}", self.o.session, self.o.ms)
331    }
332}
333
334// --- Where the checks meet the policy core ---------------------------------------------------------
335
336/// `f` on `s` with the gate's budget at 0 (P0), `s` as it was afterwards: the gate's state is swapped
337/// out and back, never cloned with the summary, so a replay stays linear in the log.
338fn gate_closed<T>(s: &mut Summary, f: impl FnOnce(&Summary) -> T) -> T {
339    let gate = std::mem::take(&mut s.gate);
340    let out = f(s);
341    s.gate = gate;
342    out
343}
344
345/// The evaluation's check `c` as the policy sees it: its state file logs no pool tokens (the
346/// baseline's).
347fn eval_check(c: &EvalCheck) -> policy::Check<'_> {
348    policy::Check { session: &c.session, stop: c.channel == "stop", tokens: 0 }
349}
350
351/// The decision rule without the question-count test (P4a, P4b): select/policy.rs
352/// `decide_on_evidence` on `fit` at the evaluation's check `c`, for a session that had none of its
353/// lessons (the state lines log no `nofree`). It draws nothing, so it takes no seed.
354fn evidence(units: &[Unit], c: &EvalCheck, fit: &Fit) -> Vec<Route> {
355    policy::decide_on_evidence(units, &eval_check(c), fit, &[])
356}
357
358/// One draw of the question-count test, ungated (P4c): select/policy.rs `choose` on `fit` (the n = 0
359/// prior, under which the decision rule deviates nowhere when P4a passes) at the evaluation's check
360/// `c`, against a gate that admits every draw (`OPEN`) at the default tolerance (`policy::ALPHA`), for
361/// a session that had none of its lessons.
362fn tested(units: &[Unit], c: &EvalCheck, fit: &Fit, seed: u64) -> Vec<Route> {
363    let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: OPEN, ..summary::Gate::default() };
364    policy::choose(units, &eval_check(c), fit, &gate, policy::ALPHA, seed, &[]).routes
365}
366
367/// The quality `r` risks below the fixed rule at the gate's 5% quantile (P3): select/policy.rs
368/// `Decision::risk`, which the gate admitted only within its budget; a decision that keeps the fixed
369/// rule risks nothing.
370fn risk(r: &Routed, deviates: bool) -> f64 {
371    if deviates { r.log.risk } else { 0.0 }
372}
373
374/// A unit's prediction, fitted on the labels before it.
375type Predictor<'a> = Box<dyn Fn(&Label) -> f64 + 'a>;
376
377/// The value model P2 predicts by: the one the policy decides with (select/policy.rs `fit`), or, in
378/// the pre-registered rerun after its failure, the model pruned to grade, route and dilution
379/// (`fit_pruned`).
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381enum Value {
382    Full,
383    Pruned,
384}
385
386impl Value {
387    fn fit(self, s: &Summary) -> Fit {
388        match self {
389            Value::Full => policy::fit(s, None),
390            Value::Pruned => policy::fit_pruned(s, None),
391        }
392    }
393
394    /// Its n = 0 prior, for a unit with no label before its check.
395    fn prior(self) -> Fit {
396        match self {
397            Value::Full => policy::prior(),
398            Value::Pruned => policy::prior_pruned(),
399        }
400    }
401}
402
403/// The value model `value` fitted on `past` as known before `at` (P2): its fit of the log `past` makes
404/// (`known`), its n = 0 prior's (`Value::prior`) while it has no label, and per unit
405/// `policy::predict`: P(changed ∧ avoids) on its logged route among the check's applies of that route,
406/// its features read from its check's record (`checks[l.check].o`, apply `l.j`) as select/summary.rs
407/// `fold` reads them.
408fn model<'a>(checks: &'a [Check], past: &[&Label], at: i64, value: Value) -> Option<Predictor<'a>> {
409    let fit = Some(value.fit(&known(checks, past, at))).filter(|f| f.model.is_some()).unwrap_or_else(|| value.prior());
410    Some(Box::new(move |l: &Label| {
411        let o = &checks[l.check].o;
412        let routes = routes_of(&checks[l.check]);
413        let units: Vec<Unit> = o
414            .lessons
415            .iter()
416            .zip(&o.grades)
417            .enumerate()
418            .map(|(j, (lesson, grade))| Unit { lesson, grade: *grade, rank: o.ranks.get(j).map_or(j, |r| usize::from(*r)) })
419            .collect();
420        let route = [Route::Ask, Route::Free, Route::Not][l.route];
421        let m = routes.iter().filter(|r| **r == route).count();
422        let feats = policy::Feat::of(&units, o.channel == "stop", o.tokens);
423        policy::predict(&fit, &feats[l.j], route, m, &o.session, &o.lessons[l.j]).unwrap_or(0.5)
424    }))
425}
426
427/// A check's route per apply, as select/summary.rs `fold` reads its record: asked, else free, else
428/// placebo, else not now.
429fn routes_of(c: &Check) -> Vec<Route> {
430    let has = |v: &[String], l: &String| v.contains(l);
431    c.o.lessons
432        .iter()
433        .map(|l| match () {
434            _ if has(&c.asked, l) => Route::Ask,
435            _ if has(&c.free, l) => Route::Free,
436            _ if has(&c.o.placebo, l) => Route::Placebo,
437            _ => Route::Not,
438        })
439        .collect()
440}
441
442/// The summary a fit on exactly `past` as known before `at` reads: every check up to the newest of
443/// their results then and every check of theirs (as logged, in log order, an older one given a check id
444/// of its own), then each label's verdict as known before `at` (`Label::at`) joined to its check by id,
445/// on its route, by its judge. Leave-future-out by construction: no other verdict is in it, and no
446/// result from `at` on.
447fn known(checks: &[Check], past: &[&Label], at: i64) -> Summary {
448    let then: Vec<(&Label, (i64, f64))> = past.iter().filter_map(|l| l.at(at).map(|k| (*l, k))).collect();
449    let Some(until) = then.iter().map(|(_, (ms, _))| *ms).max() else { return Summary::default() };
450    // An id no logged check has (they are `select::CHECK_HEX` hex digits).
451    let id = |i: usize| if checks[i].o.check.is_empty() { format!("replay-{i}") } else { checks[i].o.check.clone() };
452    let theirs: BTreeSet<usize> = then.iter().map(|(l, _)| l.check).collect();
453    let mut log: Vec<Outcome> =
454        checks.iter().enumerate().filter(|(i, c)| c.o.ms <= until || theirs.contains(i)).map(|(i, c)| Outcome { check: id(i), ..c.o.clone() }).collect();
455    for (l, (ms, y)) in then {
456        let o = &checks[l.check].o;
457        log.push(Outcome {
458            kind: if y > 0.5 { outcome::CHANGED } else { outcome::IGNORED }.into(),
459            session: o.session.clone(),
460            lessons: vec![o.lessons[l.j].clone()],
461            ms,
462            channel: match l.route {
463                ASK => channel(o).into(),
464                FREE => "queued".into(),
465                _ => outcome::PLACEBO.into(),
466            },
467            check: id(l.check),
468            judge: l.judge,
469            ..Outcome::default()
470        });
471    }
472    let text: String = log.iter().filter_map(|o| serde_json::to_string(o).ok()).map(|j| j + "\n").collect();
473    summary::fold(&text, Summary::default())
474}
475
476// --- Pass 1: the log as logged (P0, P1) -----------------------------------------------------------
477
478#[derive(Debug, Clone, PartialEq, Serialize)]
479pub struct P0 {
480    pub verdict: Verdict,
481    /// Graded checks decided at gate budget 0.
482    pub checks: usize,
483    /// Of them, the checks that apply no lesson (none applied, or the call failed): every policy asks
484    /// and hands over nothing there, so they are compared but test nothing, and P0 is NOT TESTED when
485    /// they are all it has.
486    pub empty: usize,
487    /// Checks whose decision is not the fixed rule's, or (logged under the fixed rule) not the logged one.
488    pub differ: usize,
489    pub first: Vec<String>,
490}
491
492#[derive(Debug, Clone, PartialEq, Serialize)]
493pub struct P1 {
494    pub verdict: Verdict,
495    /// Checks replayed (fixed rule, learned policy), and those that reproduced their log.
496    pub replayed: usize,
497    pub reproduced: usize,
498    /// Of the replayed checks, those that apply no lesson (P0 `empty`): their decision and propensities
499    /// are empty under any policy, so only the summary and the source their record names are tested
500    /// there, and P1 is NOT TESTED when they are all it replayed.
501    pub empty: usize,
502    /// Replayed checks whose record names the summary its decision read (`Outcome::seen`); the others
503    /// (older records) are replayed on every line before them.
504    pub seen: usize,
505    /// Of the replayed checks, those whose record logs the question-count test (outcome.rs `xk`): their
506    /// draw and its propensities are replayed from the check's seed.
507    pub tests: usize,
508    /// Checks under an evaluation switch (cap, learned cut): counted, not replayed.
509    pub switches: usize,
510    /// Checks imported from another store's log (`imported`): the summary or fit their record names
511    /// lies at a byte that is no line boundary of this log, so no summary of it is the one their hook
512    /// read. Counted, not replayed.
513    pub imported: usize,
514    /// The largest |Δ| between a replayed and a logged propensity (p or pf).
515    pub max_delta: f64,
516    /// Folding the log check by check gives the summary that folding it whole gives.
517    pub fold: bool,
518    pub first: Vec<String>,
519}
520
521/// Pass 1's results: the graded checks (in log order), P0, P1 and the store's posterior summary.
522struct AsLogged {
523    checks: Vec<Check>,
524    malformed: usize,
525    p0: P0,
526    p1: P1,
527    posterior: Summary,
528}
529
530/// One fit's summary as the replay folds it on (`fitted`): the fit's offset `at` and what the refit set
531/// there (summary.rs `with_fit`: the fit, the gate's ledger, the monitor), and the summary folded on
532/// to byte `pos`.
533struct Fitted {
534    at: usize,
535    refit: (Fit, summary::Gate, policy::Monitor),
536    s: Summary,
537    pos: usize,
538}
539
540/// The summary a hook decided with under fit `key` when it had read the log up to byte `upto` of
541/// `text`: the refit (summary.rs `with_fit`, the collective's `budget` included) of the log up to the
542/// fit's offset (the key's `at`), then the log from there folded on it as summary.rs `load` does. Kept
543/// per key and folded on as the records come, so each fit's tail is folded about once; a decision that
544/// read less of the log than the last one under the same fit (two checks at once, `Outcome::seen`) is
545/// folded again from the fit's offset. None when no refit of the log there gives that key, or the
546/// decision read less than the fit.
547fn fitted<'a>(cache: &'a mut HashMap<String, Option<Fitted>>, text: &str, upto: usize, key: &str, budget: Option<f64>) -> Option<&'a mut Summary> {
548    let entry = cache.entry(key.to_string()).or_insert_with(|| {
549        let at: usize = key.split('-').next()?.parse().ok()?;
550        if !text.is_char_boundary(at) {
551            return None;
552        }
553        let s = summary::with_fit(summary::fold(&text[..at], Summary::default()), budget);
554        (s.fit.key == key).then(|| Fitted { at, refit: (s.fit.clone(), s.gate.clone(), s.monitor.clone()), s, pos: at })
555    });
556    let f = entry.as_mut().filter(|f| f.at <= upto)?;
557    if upto < f.pos {
558        // A fresh fold with what the refit set is the refit itself (`with_fit` changes nothing else).
559        let mut s = summary::fold(&text[..f.at], Summary::default());
560        (s.fit, s.gate, s.monitor) = f.refit.clone();
561        (f.s, f.pos) = (s, f.at);
562    }
563    if f.pos < upto {
564        f.s = summary::fold(&text[f.pos..upto], std::mem::take(&mut f.s));
565        f.pos = upto;
566    }
567    Some(&mut f.s)
568}
569
570/// The byte of `text` up to which the decision of the graded record at byte `here` read the log: the
571/// summary its record names (`Outcome::seen`, read before the model call, so it can end before
572/// records other checks appended meanwhile), else every line before the record. Err when the named
573/// summary is not a line boundary before the record.
574fn seen_at(o: &Outcome, text: &str, here: usize) -> Result<usize, String> {
575    if o.seen.is_empty() {
576        return Ok(here);
577    }
578    let at = o.seen.split('-').next().and_then(|a| a.parse::<usize>().ok());
579    at.filter(|&a| a <= here && text.is_char_boundary(a) && (a == 0 || text[..a].ends_with('\n')))
580        .ok_or_else(|| format!("its summary {:?} is no line boundary before it", o.seen))
581}
582
583/// Whether the graded record `o` came with history copied from another store's log: the summary or the
584/// fit it names (`Outcome::seen`, `Outcome::fit`, each keyed by the byte of the log it was folded to)
585/// lies at a byte that is no line boundary of `text` (past its end included). A hook of this store only
586/// ever folds whole lines, so such a key is another log's. A key at a boundary is this log's, whatever
587/// else is wrong with it (P1 then fails it), and one whose byte does not parse is no import either.
588fn imported(o: &Outcome, text: &str) -> bool {
589    let off = |key: &str| -> bool {
590        let Some(at) = key.split('-').next().and_then(|a| a.parse::<usize>().ok()) else { return false };
591        !(at <= text.len() && text.is_char_boundary(at) && (at == 0 || text[..at].ends_with('\n')))
592    };
593    off(&o.seen) || off(&o.fit)
594}
595
596fn as_logged(text: &str, lessons: &Path) -> AsLogged {
597    let mut s = Summary::default();
598    // Per fit key the hook logged, the summary it decided with (`fitted`).
599    let mut fits: HashMap<String, Option<Fitted>> = HashMap::new();
600    let budget = super::budget_of(lessons);
601    let mut at = 0;
602    let mut checks = Vec::new();
603    let mut malformed = 0;
604    let mut p0 = P0 { verdict: Verdict::Pass, checks: 0, empty: 0, differ: 0, first: Vec::new() };
605    let mut p1 = P1 {
606        verdict: Verdict::Pass,
607        replayed: 0,
608        reproduced: 0,
609        empty: 0,
610        seen: 0,
611        tests: 0,
612        switches: 0,
613        imported: 0,
614        max_delta: 0.0,
615        fold: true,
616        first: Vec::new(),
617    };
618    let mut start = 0;
619    for line in lines(text) {
620        let here = start;
621        start += line.text.len();
622        let Some(o) = line.o.filter(|o| o.kind == outcome::GRADED) else { continue };
623        let Some(cs) = challenges(&o) else {
624            malformed += 1;
625            continue;
626        };
627        // The summary the hook had: every line before this record.
628        s = summary::fold(&text[at..here], s);
629        at = here;
630        let applied = applied(&cs, &o);
631        let check = Check::new(o, &applied);
632        // P0: at gate budget 0 the learned policy is the fixed rule, and the fixed rule is what was logged.
633        let r = gate_closed(&mut s, |s| super::graded_under(Policy::Learned, &applied, &ctx(&check.o, &check.had, lessons, s)));
634        let mut same = (ids(&r.ask), ids(&r.free)) == check.fixed;
635        if replayable(&check.o) == Some(Policy::Fixed) {
636            same &= (&check.asked, &check.free) == (&check.fixed.0, &check.fixed.1);
637        }
638        p0.checks += 1;
639        p0.empty += usize::from(applied.is_empty());
640        if !same {
641            p0.differ += 1;
642            if p0.first.len() < SHOW {
643                p0.first.push(format!(
644                    "{}: gate 0 asks {:?} free {:?}; logged {:?} free {:?}; fixed rule {:?} free {:?}",
645                    check.line(),
646                    ids(&r.ask),
647                    ids(&r.free),
648                    check.asked,
649                    check.free,
650                    check.fixed.0,
651                    check.fixed.1
652                ));
653            }
654        }
655        // P1: the logged policy from its logged fit, summary and seed.
656        match replayable(&check.o) {
657            None => p1.switches += 1,
658            Some(_) if imported(&check.o, text) => p1.imported += 1,
659            Some(policy) => {
660                p1.replayed += 1;
661                p1.empty += usize::from(applied.is_empty());
662                p1.seen += usize::from(!check.o.seen.is_empty());
663                p1.tests += usize::from(check.o.xk.is_some());
664                let found = match seen_at(&check.o, text, here) {
665                    Err(e) => Err(e),
666                    // The prior has no model: its decision is the fixed rule whatever else the summary holds.
667                    Ok(_) if check.o.fit.is_empty() => Ok(&mut s),
668                    Ok(upto) => fitted(&mut fits, text, upto, &check.o.fit, budget)
669                        .filter(|f| check.o.seen.is_empty() || f.key() == check.o.seen)
670                        .ok_or_else(|| format!("no summary with fit {:?} and key {:?} before it", check.o.fit, check.o.seen)),
671                };
672                let miss = match found {
673                    Err(e) => Some(format!("{}: {e}", check.line())),
674                    Ok(_) if policy == Policy::Learned && check.o.check.is_empty() => Some(format!("{}: learned without a check id", check.line())),
675                    Ok(at) => {
676                        // The store's revert as the hook read it: its summary ORs in the sticky mark beside
677                        // the store (summary.rs `REVERTED`), which no log line holds; select.rs `learned`
678                        // logs it as its source.
679                        let logged = !check.o.source.is_empty();
680                        let kept = at.monitor.reverted;
681                        if logged {
682                            at.monitor.reverted = check.o.source == super::REVERTED_SOURCE;
683                        }
684                        let r = super::graded_under(policy, &applied, &ctx(&check.o, &check.had, lessons, at));
685                        at.monitor.reverted = kept;
686                        let delta = r.p.iter().zip(&check.p).chain(r.pf.iter().zip(&check.pf)).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
687                        p1.max_delta = p1.max_delta.max(delta);
688                        // The test's draw: k and s as logged (None outside the test, and on every record
689                        // from before it).
690                        let drawn = (r.log.xk, r.log.xs) == (check.o.xk, check.o.xs);
691                        let decided = ids(&r.ask) == check.asked && ids(&r.free) == check.free && (!logged || (r.log.source == check.o.source && drawn));
692                        let aligned = r.p.len() == check.p.len() && r.pf.len() == check.pf.len();
693                        let x = |k: Option<u32>, s: Option<u32>| match (k, s) {
694                            (None, None) => String::new(),
695                            (k, s) => format!(" k {} s {}", k.map_or("-".into(), |k| k.to_string()), s.map_or("-".into(), |s| s.to_string())),
696                        };
697                        (!decided || !aligned || delta > ope::AGREE).then(|| {
698                            format!(
699                                "{}: replay asks {:?} free {:?} by {}{} (|Δp| {delta:.4}); logged {:?} free {:?} by {}{}",
700                                check.line(),
701                                ids(&r.ask),
702                                ids(&r.free),
703                                r.log.source,
704                                x(r.log.xk, r.log.xs),
705                                check.asked,
706                                check.free,
707                                if logged { check.o.source.as_str() } else { "-" },
708                                x(check.o.xk, check.o.xs)
709                            )
710                        })
711                    }
712                };
713                match miss {
714                    None => p1.reproduced += 1,
715                    Some(m) if p1.first.len() < SHOW => p1.first.push(m),
716                    Some(_) => {}
717                }
718            }
719        }
720        checks.push(check);
721    }
722    let posterior = summary::fold(&text[at..], s);
723    p1.fold = posterior == summary::fold(text, Summary::default());
724    // Tested only by a check that applies a lesson: on the others every policy decides nothing (`empty`).
725    p0.verdict = Verdict::tested(p0.differ == 0, p0.checks > p0.empty);
726    p1.verdict = Verdict::tested(p1.reproduced == p1.replayed && p1.fold, p1.replayed > p1.empty);
727    AsLogged { checks, malformed, p0, p1, posterior }
728}
729
730// --- Pass 2: cold start (P3, and the policy P5 evaluates) ------------------------------------------
731
732/// What a decision spends, summed over checks: model calls, questions asked, blocks at the stop (an
733/// extra round each), lessons handed over free, the pool's tokens where logged, and the logged calls'
734/// cost in dollars (`usd`, the same for any policy replayed on them: a replay makes no call). Measured,
735/// never priced.
736#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
737pub struct Spend {
738    pub checks: usize,
739    pub questions: usize,
740    pub blocks: usize,
741    pub free: usize,
742    pub tokens: u64,
743    pub usd: f64,
744}
745
746impl Spend {
747    fn add(&mut self, o: &Outcome, asked: usize, free: usize) {
748        self.checks += 1;
749        self.questions += asked;
750        self.blocks += usize::from(channel(o) == "stop" && asked > 0);
751        self.free += free;
752        self.tokens += u64::from(o.tokens);
753        self.usd += o.usd;
754    }
755}
756
757/// One check of the cold-start replay: whether the policy left the fixed rule, the gate's budget at
758/// that check and the quality the decision risked below the fixed rule.
759#[derive(Debug, Clone, PartialEq)]
760struct Step {
761    deviates: bool,
762    gate: f64,
763    risk: f64,
764    at: String,
765}
766
767#[derive(Debug, Clone, PartialEq, Serialize)]
768pub struct P3 {
769    pub verdict: Verdict,
770    /// Why it is not tested: no check, or no decision left the fixed rule, so the gate was never put to
771    /// the test. Empty otherwise.
772    pub why: String,
773    pub checks: usize,
774    pub deviations: usize,
775    /// The first check decided as the fixed rule with a budget of 0.
776    pub cold: bool,
777    /// Checks whose decision risked more than the gate's budget there, or whose budget was negative.
778    pub over: usize,
779    /// The engineer's budget for the collective (select.rs `budget_of`: pool tokens per session), by
780    /// which the policy prices pool tokens; none by default. With one, the replayed pools must stay
781    /// within it (`pool`); spend is reported either way.
782    pub budget: Option<f64>,
783    /// The pool tokens the replayed policy's pools spend per session, as select/policy.rs `price`
784    /// measures what a budget bounds: each check at the pool budget `pool_tokens` chose for it, averaged
785    /// over the sessions with a check.
786    pub pool: f64,
787    /// The refits the replay made, one wherever a session had ended since the last (`cold_start`).
788    pub refits: usize,
789    /// The replayed decisions per source (select/policy.rs `Decision::source`): how many the evidence
790    /// rule, the question-count test (its control arm apart) and the gate's refusals set.
791    pub sources: BTreeMap<String, usize>,
792    pub policy: Spend,
793    pub fixed: Spend,
794    pub first: Vec<String>,
795}
796
797/// P3 over the cold-start steps, and the replayed pools' spend per session (`pool`) against the
798/// engineer's `budget` (the spends are carried through).
799fn conduct(steps: &[Step], policy: Spend, fixed: Spend, budget: Option<f64>, pool: f64) -> P3 {
800    let cold = steps.first().is_none_or(|s| !s.deviates && s.gate == 0.0);
801    let bad: Vec<&Step> = steps.iter().filter(|s| s.gate.is_nan() || s.gate < 0.0 || s.risk > s.gate).collect();
802    let mut first: Vec<String> = bad.iter().take(SHOW).map(|s| format!("{}: risked {} with a budget of {:.4}", s.at, s.risk, s.gate)).collect();
803    let over_budget = budget.filter(|b| pool > *b);
804    if let Some(b) = over_budget {
805        first.push(format!("the policy's pools spend {pool:.0} tokens per session, over the collective's budget of {b}"));
806    }
807    let deviations = steps.iter().filter(|s| s.deviates).count();
808    let verdict = Verdict::tested(cold && bad.is_empty() && over_budget.is_none(), deviations > 0);
809    let why = match (verdict, steps.len()) {
810        (Verdict::NotTested, 0) => "no graded check to replay".to_string(),
811        (Verdict::NotTested, _) => "no decision left the fixed rule, so the gate never had to hold one back".to_string(),
812        _ => String::new(),
813    };
814    P3 { verdict, why, checks: steps.len(), deviations, cold, over: bad.len(), budget, pool, refits: 0, sources: BTreeMap::new(), policy, fixed, first }
815}
816
817/// A check's propensities of asking (`p`) and of handing over free (`pf`), aligned with its applies.
818type Propensities = (Vec<f64>, Vec<f64>);
819
820/// The latency limit the replay's pools are chosen under: the loosest, which no pool's latency
821/// exceeds. Method: as select/policy.rs `price` replays pools, so the spend P3 holds against an
822/// engineer's budget is the most a hook's limit (the harness's stop budget) allows; no limit at all
823/// (None) would instead forbid every pool above the baseline.
824const LOOSEST: Option<u64> = Some(u64::MAX);
825
826/// The pool budget the learned policy chooses for a check of `session` on `s` (select.rs
827/// `pool_tokens_under`, whatever this process's `policy`), under `LOOSEST`.
828fn pool_of(s: &Summary, session: &str) -> usize {
829    super::pool_tokens_under(Policy::Learned, s, LOOSEST, session)
830}
831
832/// The graded record `o` as the decision `r` would have logged it (hook.rs `record_graded`): its
833/// routes, its own propensities (the placebo's `psi` weighs the monitor's estimate, select/policy.rs
834/// `monitor`), policy, fit, source and the question-count test's k and s (select/summary.rs counts
835/// them); its applies, had lessons (`nofree`), pool and tail stay the
836/// logged check's.
837fn decided(o: Outcome, r: &Routed) -> Outcome {
838    let round = |v: &[f64]| v.iter().map(|p| outcome::round_p(*p)).collect();
839    Outcome {
840        chosen: ids(&r.ask),
841        free: ids(&r.free),
842        placebo: ids(&r.placebo),
843        p: round(&r.p),
844        pf: round(&r.pf),
845        psi: r.log.psi,
846        policy: r.log.policy.into(),
847        fit: r.log.fit.clone(),
848        source: r.log.source.into(),
849        xk: r.log.xk,
850        xs: r.log.xs,
851        explore: false,
852        ..o
853    }
854}
855
856/// The cold-start replay: P3, and per graded check (aligned with pass 1's) the learned policy's
857/// propensities. The policy decides as a hook would: with the summary of every line before the check,
858/// fitted where production fits it, at `borg learn` when a session ends (main.rs `learn_store`, after
859/// the session's settle): refitted (summary.rs `with_fit`, the pure part of the `refit` that
860/// `learn_store` calls, under the collective's `budget`) before the first check after a session ended,
861/// the gate's ledger replayed over the policy's own checks; and under the had lessons its record logs
862/// (`had_of`). Each check's pool is the budget the policy chooses there (select.rs `pool_tokens_under`
863/// the learned policy, called per check as the hook calls `pool_tokens`) under the loosest latency
864/// limit (`pool_of`).
865fn cold_start(text: &str, lessons: &Path, budget: Option<f64>) -> (P3, Vec<Propensities>) {
866    let mut s = Summary::default();
867    let mut chunk = String::new();
868    let (mut steps, mut targets) = (Vec::new(), Vec::new());
869    let (mut spent, mut fixed) = (Spend::default(), Spend::default());
870    let (mut ended, mut refits) = (0, 0);
871    // Pool tokens per session; decisions per source.
872    let mut pools: BTreeMap<String, u64> = BTreeMap::new();
873    let mut sources: BTreeMap<String, usize> = BTreeMap::new();
874    for line in lines(text) {
875        let Some((o, cs)) = line.o.filter(|o| o.kind == outcome::GRADED).and_then(|o| challenges(&o).map(|cs| (o, cs))) else {
876            chunk.push_str(line.text);
877            continue;
878        };
879        s = summary::fold(&chunk, s);
880        chunk.clear();
881        // Ended sessions the summary holds; a compaction (summary.rs `MAX_SESSIONS`) drops some, after
882        // which the next end counts from what is left.
883        let now = s.sessions.iter().filter(|x| x.ended).count();
884        ended = ended.min(now);
885        if now > ended {
886            s = summary::with_fit(s, budget);
887            (ended, refits) = (now, refits + 1);
888        }
889        let applied = applied(&cs, &o);
890        let had = had_of(&o);
891        let r = super::graded_under(Policy::Learned, &applied, &ctx(&o, &had, lessons, &s));
892        let (fa, ff) = conscience::graded_without(&applied, None, &had);
893        let deviates = (ids(&r.ask), ids(&r.free)) != (ids(&fa), ids(&ff));
894        spent.add(&o, r.ask.len(), r.free.len());
895        fixed.add(&o, fa.len(), ff.len());
896        let pool = pool_of(&s, &o.session);
897        *pools.entry(o.session.clone()).or_default() += pool as u64;
898        *sources.entry(r.log.source.to_string()).or_default() += 1;
899        steps.push(Step { deviates, gate: r.log.gate, risk: risk(&r, deviates), at: format!("session {} ms {}", o.session, o.ms) });
900        targets.push((r.p.clone(), r.pf.clone()));
901        // The policy's own decision is what the summary folds next.
902        match serde_json::to_string(&decided(o, &r)) {
903            Ok(j) => {
904                chunk.push_str(&j);
905                chunk.push('\n');
906            }
907            Err(_) => chunk.push_str(line.text),
908        }
909    }
910    let pool = if pools.is_empty() { 0.0 } else { pools.values().sum::<u64>() as f64 / pools.len() as f64 };
911    let p3 = P3 { refits, sources, ..conduct(&steps, spent, fixed, budget, pool) };
912    (p3, targets)
913}
914
915// --- Labels (P2, P5, the readout) -----------------------------------------------------------------
916
917/// One judged unit: a graded check's apply with the strongest result of its delivery.
918#[derive(Debug, Clone, PartialEq)]
919struct Label {
920    /// Its check (index into pass 1's checks) and its place among the check's applies.
921    check: usize,
922    j: usize,
923    grade: u8,
924    /// The route that delivered it (`ASK`, `FREE`, `NOT` for a placebo) and each route's logged
925    /// probability of a label (`NOT`'s: routed nowhere and drawn as a placebo).
926    route: usize,
927    mu: [f64; ROUTES],
928    /// 1 changed and avoided, 0 not: from every result of it (the last of `known`).
929    y: f64,
930    session: String,
931    check_ms: i64,
932    /// Its value as the log knew it: from each result's ms on that changed it, the value then, in time
933    /// order, the first at its first verdict. A prediction for a check at t reads the value before t
934    /// (`at`): a veto, correction or stronger verdict after t is a result from after t.
935    known: Vec<(i64, f64)>,
936    /// The judge template version of its first verdict (outcome.rs `judge`).
937    judge: u8,
938}
939
940impl Label {
941    /// Its first result's ms: before it no check could know it.
942    fn first(&self) -> i64 {
943        self.known.first().map_or(i64::MAX, |k| k.0)
944    }
945
946    /// Its value as known before `t`, with the ms of the result that set it; None before its first.
947    fn at(&self, t: i64) -> Option<(i64, f64)> {
948        self.known.iter().take_while(|(ms, _)| *ms < t).last().copied()
949    }
950}
951
952/// A unit's value over its results (each its ms and `ope::rank`), in time order: 1 while its strongest
953/// result so far is the change, 0 otherwise, from its first verdict on; an entry per change.
954fn values(verdicts: &[(i64, u8)], against: &[(i64, u8)]) -> Vec<(i64, f64)> {
955    let Some(start) = verdicts.iter().map(|v| v.0).min() else { return Vec::new() };
956    let mut events: Vec<(i64, u8)> = verdicts.iter().chain(against).copied().collect();
957    events.sort_unstable();
958    let mut out: Vec<(i64, f64)> = Vec::new();
959    let mut strongest = 0;
960    for (i, &(ms, rank)) in events.iter().enumerate() {
961        strongest = strongest.max(rank);
962        // Results at the same ms land together.
963        if ms < start || events.get(i + 1).is_some_and(|e| e.0 == ms) {
964            continue;
965        }
966        let y = f64::from(u8::from(strongest == 1));
967        if out.last().is_none_or(|l| l.1 != y) {
968            out.push((ms, y));
969        }
970    }
971    out
972}
973
974/// The judged units of `checks` from the results in `log`, in check order, and the verdicts that
975/// joined no check.
976fn labels(checks: &[Check], log: &[Outcome]) -> (Vec<Label>, usize) {
977    let by_id: HashMap<&str, usize> = checks.iter().enumerate().filter(|(_, c)| !c.o.check.is_empty()).map(|(i, c)| (c.o.check.as_str(), i)).collect();
978    let mut by_lesson: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
979    for (i, c) in checks.iter().enumerate() {
980        for l in &c.o.lessons {
981            by_lesson.entry((c.o.session.as_str(), l.as_str())).or_default().push(i);
982        }
983    }
984    let routed = |c: &Check, route: usize, l: &str| match route {
985        ASK => c.asked.iter().any(|x| x == l),
986        FREE => c.free.iter().any(|x| x == l),
987        _ => c.o.placebo.iter().any(|x| x == l),
988    };
989    // A unit's verdicts (ms, rank), the route that delivered it, and its first verdict's ms and judge.
990    struct Joined {
991        verdicts: Vec<(i64, u8)>,
992        route: usize,
993        first: (i64, u8),
994    }
995    let mut joined: BTreeMap<(usize, usize), Joined> = BTreeMap::new();
996    let mut unjoined = 0;
997    // A verdict that settles its delivery (`outcome::settles`): a point that never came up
998    // (`asked_not_arisen`, no rank) is a 0 as an ignore is, as the selection model's fit reads it.
999    for r in log.iter().filter(|r| outcome::settles(&r.kind)) {
1000        let route = match r.channel.as_str() {
1001            "early" => continue,
1002            "queued" => FREE,
1003            outcome::PLACEBO => NOT,
1004            _ => ASK,
1005        };
1006        let rank = ope::rank(&r.kind).unwrap_or(0);
1007        for l in &r.lessons {
1008            let at = match r.check.as_str() {
1009                "" => by_lesson
1010                    .get(&(r.session.as_str(), l.as_str()))
1011                    .and_then(|v| v.iter().rev().find(|&&i| checks[i].o.ms <= r.ms && routed(&checks[i], route, l)))
1012                    .copied(),
1013                id => by_id.get(id).copied(),
1014            };
1015            let Some((i, j)) = at.and_then(|i| checks[i].o.lessons.iter().position(|x| x == l).filter(|_| routed(&checks[i], route, l)).map(|j| (i, j))) else {
1016                unjoined += 1;
1017                continue;
1018            };
1019            let e = joined.entry((i, j)).or_insert(Joined { verdicts: Vec::new(), route, first: (r.ms, r.judge) });
1020            e.verdicts.push((r.ms, rank));
1021            if r.ms < e.first.0 {
1022                e.first = (r.ms, r.judge);
1023            }
1024        }
1025    }
1026    // A veto or correction in the session after the check outranks the change.
1027    let mut against: HashMap<(&str, &str), Vec<(i64, u8)>> = HashMap::new();
1028    for o in log.iter().filter(|o| o.kind == outcome::VETOED || o.kind == outcome::CORRECTED) {
1029        for l in &o.lessons {
1030            against.entry((o.session.as_str(), l.as_str())).or_default().push((o.ms, ope::rank(&o.kind).unwrap_or(0)));
1031        }
1032    }
1033    let out = joined
1034        .into_iter()
1035        .map(|((i, j), Joined { verdicts, route, first: (_, judge) })| {
1036            let c = &checks[i];
1037            let l = c.o.lessons[j].as_str();
1038            let later: Vec<(i64, u8)> = against.get(&(c.o.session.as_str(), l)).into_iter().flatten().filter(|(t, _)| *t >= c.o.ms).copied().collect();
1039            let known = values(&verdicts, &later);
1040            let (p, pf) = (c.p.get(j).copied().unwrap_or(0.0), c.pf.get(j).copied().unwrap_or(0.0));
1041            // Not delivered, a unit is judged only when the placebo draw took it (its chance ψ, `psi`):
1042            // that route's probability is both, as select/policy.rs `monitor` weighs it, a placebo that
1043            // happened at least at `PSI_FLOOR`.
1044            let psi = if route == NOT { c.o.psi.max(policy::PSI_FLOOR) } else { c.o.psi };
1045            Label {
1046                check: i,
1047                j,
1048                grade: c.o.grades[j],
1049                route,
1050                mu: [p, pf, (1.0 - p - pf).max(0.0) * psi],
1051                y: known.last().map_or(0.0, |k| k.1),
1052                session: c.o.session.clone(),
1053                check_ms: c.o.ms,
1054                known,
1055                judge,
1056            }
1057        })
1058        .collect();
1059    (out, unjoined)
1060}
1061
1062/// Judged and changed counts per grade and route.
1063type Counts = [[(u32, u32); ROUTES]; GRADES];
1064
1065/// Every change of a label's known value (`Label::known`), as (ms, label index, value), in time order.
1066fn changes(labels: &[Label]) -> Vec<(i64, usize, f64)> {
1067    let mut v: Vec<(i64, usize, f64)> = labels.iter().enumerate().flat_map(|(i, l)| l.known.iter().map(move |&(ms, y)| (ms, i, y))).collect();
1068    v.sort_by_key(|c| (c.0, c.1));
1069    v
1070}
1071
1072/// Per label, the counts of the labels whose result came before its check, each at its value then:
1073/// what a decision at that check could have known (leave-future-out).
1074fn past_counts(labels: &[Label]) -> Vec<Counts> {
1075    let changes = changes(labels);
1076    let mut order: Vec<usize> = (0..labels.len()).collect();
1077    order.sort_by_key(|&i| labels[i].check_ms);
1078    let mut counts: Counts = [[(0, 0); ROUTES]; GRADES];
1079    let mut now: Vec<Option<f64>> = vec![None; labels.len()];
1080    let mut out = vec![counts; labels.len()];
1081    let mut next = 0;
1082    for i in order {
1083        while next < changes.len() && changes[next].0 < labels[i].check_ms {
1084            let (_, k, y) = changes[next];
1085            let c = &mut counts[usize::from(labels[k].grade).min(GRADES - 1)][labels[k].route];
1086            if let Some(was) = now[k] {
1087                c.0 -= 1;
1088                c.1 -= u32::from(was > 0.5);
1089            }
1090            c.0 += 1;
1091            c.1 += u32::from(y > 0.5);
1092            now[k] = Some(y);
1093            next += 1;
1094        }
1095        out[i] = counts;
1096    }
1097    out
1098}
1099
1100/// Beta(1, 1)'s posterior mean after `k` of `n`.
1101fn beta(n: u32, k: u32) -> f64 {
1102    (1.0 + f64::from(k)) / (2.0 + f64::from(n))
1103}
1104
1105/// The grade-only Beta-Binomial's prediction of a unit of `grade` (every route pooled).
1106fn by_grade(c: &Counts, grade: u8) -> f64 {
1107    let row = &c[usize::from(grade).min(GRADES - 1)];
1108    beta(row.iter().map(|x| x.0).sum(), row.iter().map(|x| x.1).sum())
1109}
1110
1111fn log_loss(p: f64, y: f64) -> f64 {
1112    let p = p.clamp(CLAMP, 1.0 - CLAMP);
1113    -(y * p.ln() + (1.0 - y) * (1.0 - p).ln())
1114}
1115
1116/// Session index per label, in first-seen order, for the session bootstrap.
1117fn sessions(labels: &[Label]) -> Vec<usize> {
1118    let mut seen: HashMap<&str, usize> = HashMap::new();
1119    labels
1120        .iter()
1121        .map(|l| {
1122            let n = seen.len();
1123            *seen.entry(l.session.as_str()).or_insert(n)
1124        })
1125        .collect()
1126}
1127
1128// --- P2 -------------------------------------------------------------------------------------------
1129
1130/// A prediction this close to 1/2 is a coin's: P2 is NOT TESTED when every prediction of both models
1131/// is one. Method: float slack around an exact 1/2 (Beta(1, 1) at k = n/2, a model's η = 0), far
1132/// below anything a label moves.
1133const COIN: f64 = 1e-9;
1134
1135#[derive(Debug, Clone, PartialEq, Serialize)]
1136pub struct P2 {
1137    pub verdict: Verdict,
1138    pub why: String,
1139    pub labels: usize,
1140    pub sessions: usize,
1141    /// Judged verdicts that joined no graded check (an early delivery's never does).
1142    pub unjoined: usize,
1143    /// Judged units with a label known before their check: those both models predict from data.
1144    pub informed: usize,
1145    /// Mean leave-future-out log-loss, grade only and of the value model.
1146    pub grade_only: f64,
1147    pub model: Option<f64>,
1148    /// The mean difference (model − grade only) with its session-bootstrap 95% interval.
1149    pub diff: Option<Estimate>,
1150    /// The pre-registered rerun after the value model's FAIL, on the same units: the model pruned to
1151    /// grade, route and dilution (select/policy.rs `fit_pruned`); its verdict is P2's. None unless it
1152    /// ran.
1153    pub pruned: Option<Rerun>,
1154}
1155
1156/// One rerun of P2's comparison by another value model.
1157#[derive(Debug, Clone, PartialEq, Serialize)]
1158pub struct Rerun {
1159    pub verdict: Verdict,
1160    pub model: f64,
1161    pub diff: Estimate,
1162}
1163
1164/// A fit of P2's value model `Value` on the labels known before a check at the given ms.
1165type Fitter<'f, 'a> = dyn Fn(Value, &[&Label], i64) -> Option<Predictor<'a>> + 'f;
1166
1167/// Each label's leave-future-out prediction by `value` (`fit` refitted wherever what is known before
1168/// a check changed), or why there is none.
1169fn predictions<'a>(labels: &[Label], value: Value, fit: &Fitter<'_, 'a>) -> Result<Vec<f64>, String> {
1170    let changes = changes(labels);
1171    let mut order: Vec<usize> = (0..labels.len()).collect();
1172    order.sort_by_key(|&i| labels[i].check_ms);
1173    let mut out = vec![0.5; labels.len()];
1174    let (mut known, mut fitted): (usize, Option<(usize, Predictor<'a>)>) = (0, None);
1175    for i in order {
1176        let at = labels[i].check_ms;
1177        while known < changes.len() && changes[known].0 < at {
1178            known += 1;
1179        }
1180        if fitted.as_ref().is_none_or(|(n, _)| *n != known) {
1181            let past: Vec<&Label> = labels.iter().filter(|l| l.first() < at).collect();
1182            let Some(m) = fit(value, &past, at) else { return Err("no value model yet (select/policy.rs)".into()) };
1183            fitted = Some((known, m));
1184        }
1185        if let Some((_, m)) = &fitted {
1186            out[i] = m(&labels[i]);
1187        }
1188    }
1189    Ok(out)
1190}
1191
1192/// The model's predictions `model` against grade only's `grade` on `labels`: the verdict, its mean
1193/// log-loss and the mean difference (session bootstrap over `ss`). NOT TESTED when every prediction of
1194/// both is 1/2 (`COIN`).
1195fn compare(labels: &[Label], grade: &[f64], model: &[f64], ss: &[usize]) -> Rerun {
1196    let ll = |p: &[f64]| -> Vec<f64> { p.iter().zip(labels).map(|(p, l)| log_loss(*p, l.y)).collect() };
1197    let (g, m) = (ll(grade), ll(model));
1198    let units: Vec<(f64, f64, usize)> = m.iter().zip(&g).zip(ss).map(|((m, g), s)| (1.0, m - g, *s)).collect();
1199    let diff = ope::snips(&units, ope::SEED);
1200    let coin = |p: &[f64]| p.iter().all(|p| (p - 0.5).abs() < COIN);
1201    let verdict = Verdict::tested(diff.hi <= CALIBRATION, !(coin(grade) && coin(model)));
1202    Rerun { verdict, model: m.iter().sum::<f64>() / m.len().max(1) as f64, diff }
1203}
1204
1205/// P2 over `labels` with the value model fitted by `fit` on the labels before each unit's check; after
1206/// its FAIL, the pre-registered rerun by the pruned model (`Value::Pruned`), once.
1207fn calibration<'a>(labels: &[Label], unjoined: usize, fit: &Fitter<'_, 'a>) -> P2 {
1208    let past = past_counts(labels);
1209    let ss = sessions(labels);
1210    let grade: Vec<f64> = labels.iter().zip(&past).map(|(l, c)| by_grade(c, l.grade)).collect();
1211    let mean = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
1212    let informed = past.iter().filter(|c| c.iter().flatten().any(|x| x.0 > 0)).count();
1213    let mut p2 = P2 {
1214        verdict: Verdict::NotTested,
1215        why: String::new(),
1216        labels: labels.len(),
1217        sessions: ss.iter().collect::<BTreeSet<_>>().len(),
1218        unjoined,
1219        informed,
1220        grade_only: mean(&grade.iter().zip(labels).map(|(p, l)| log_loss(*p, l.y)).collect::<Vec<_>>()),
1221        model: None,
1222        diff: None,
1223        pruned: None,
1224    };
1225    if labels.is_empty() {
1226        p2.why = "no judged unit joins a graded check".into();
1227        return p2;
1228    }
1229    if informed == 0 {
1230        p2.why = "no judged unit has a label before its check, so neither model learned anything".into();
1231        return p2;
1232    }
1233    let full = match predictions(labels, Value::Full, fit) {
1234        Ok(p) => compare(labels, &grade, &p, &ss),
1235        Err(why) => {
1236            (p2.verdict, p2.why) = (Verdict::NotRun, why);
1237            return p2;
1238        }
1239    };
1240    (p2.verdict, p2.model, p2.diff) = (full.verdict, Some(full.model), Some(full.diff));
1241    if p2.verdict == Verdict::NotTested {
1242        p2.why = "every prediction of both models is 1/2".into();
1243    }
1244    if p2.verdict == Verdict::Fail {
1245        // Pre-registered: prune the model to grade, route and dilution and rerun once; its verdict is P2's.
1246        let pruned = match predictions(labels, Value::Pruned, fit) {
1247            Ok(p) => compare(labels, &grade, &p, &ss),
1248            Err(why) => {
1249                p2.why = format!("the pruned rerun: {why}");
1250                return p2;
1251            }
1252        };
1253        p2.verdict = pruned.verdict;
1254        p2.pruned = Some(pruned);
1255    }
1256    p2
1257}
1258
1259// --- P5 and the readout ---------------------------------------------------------------------------
1260
1261/// One policy's doubly robust change against the fixed rule (per judged unit), its own inverse
1262/// propensity estimate, and how much of its route probability the log covers.
1263#[derive(Debug, Clone, PartialEq, Serialize)]
1264pub struct Row {
1265    pub policy: String,
1266    pub dr: Estimate,
1267    pub ips: Estimate,
1268    pub coverage: f64,
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Serialize)]
1272pub struct P5 {
1273    pub verdict: Verdict,
1274    pub units: usize,
1275    /// Units delivered by a route their check gave probability 0: left out.
1276    pub inconsistent: usize,
1277    /// The learned policy first, then the fixed rule with one grade's route moved.
1278    pub rows: Vec<Row>,
1279}
1280
1281/// Per unit, a policy's probability of each route (`ASK`, `FREE`, `NOT`).
1282type Target<'a> = dyn Fn(&Label) -> [f64; ROUTES] + 'a;
1283
1284/// The fixed rule's route of a unit, as probabilities.
1285fn fixed_route(checks: &[Check], l: &Label) -> [f64; ROUTES] {
1286    let (ask, free) = &checks[l.check].fixed;
1287    let lesson = &checks[l.check].o.lessons[l.j];
1288    match (ask.contains(lesson), free.contains(lesson)) {
1289        (true, _) => [1.0, 0.0, 0.0],
1290        (_, true) => [0.0, 1.0, 0.0],
1291        _ => [0.0, 0.0, 1.0],
1292    }
1293}
1294
1295/// The DR change of `target` against `base` per unit, its IPS value and coverage: with q̂ the
1296/// leave-future-out Beta(1, 1) per grade and route, a unit adds Σ_a (π_a − b_a)·(q̂_a + [a = its
1297/// route]·(y − q̂_a)/μ_a). Session bootstrap, as `ope::snips`.
1298fn doubly_robust(labels: &[Label], past: &[Counts], ss: &[usize], target: &Target, base: &Target, name: &str) -> Row {
1299    let (mut dr, mut ips, mut covered) = (Vec::new(), Vec::new(), 0.0);
1300    for ((l, c), s) in labels.iter().zip(past).zip(ss) {
1301        let mu = l.mu[l.route];
1302        if mu <= 0.0 {
1303            continue;
1304        }
1305        let (t, b) = (target(l), base(l));
1306        let row = &c[usize::from(l.grade).min(GRADES - 1)];
1307        let q: [f64; ROUTES] = std::array::from_fn(|a| beta(row[a].0, row[a].1));
1308        let inc: f64 = (0..ROUTES).map(|a| (t[a] - b[a]) * (q[a] + if a == l.route { (l.y - q[a]) / mu } else { 0.0 })).sum();
1309        dr.push((1.0, inc, *s));
1310        ips.push((t[l.route] / mu, l.y, *s));
1311        covered += (0..ROUTES).filter(|&a| l.mu[a] > 0.0).map(|a| t[a]).sum::<f64>();
1312    }
1313    let n = dr.len().max(1) as f64;
1314    Row { policy: name.into(), dr: ope::snips(&dr, ope::SEED), ips: ope::snips(&ips, ope::SEED), coverage: covered / n }
1315}
1316
1317/// P5: the learned policy (`targets`, the cold-start replay's propensities per check) and the fixed
1318/// rule with one grade's asks handed over free, or its free lessons asked, against the fixed rule.
1319fn off_policy(checks: &[Check], labels: &[Label], targets: &[Propensities]) -> P5 {
1320    let past = past_counts(labels);
1321    let ss = sessions(labels);
1322    let base = |l: &Label| fixed_route(checks, l);
1323    let learned = |l: &Label| {
1324        let (p, pf) = targets.get(l.check).map_or((0.0, 0.0), |(p, pf)| (p.get(l.j).copied().unwrap_or(0.0), pf.get(l.j).copied().unwrap_or(0.0)));
1325        [p, pf, (1.0 - p - pf).max(0.0)]
1326    };
1327    let mut rows = vec![doubly_robust(labels, &past, &ss, &learned, &base, "learned")];
1328    for grade in 1..=3u8 {
1329        for (from, to, name) in [(ASK, FREE, "free"), (FREE, ASK, "asked")] {
1330            let moved = |l: &Label| {
1331                let b = base(l);
1332                match l.grade == grade && b[from] == 1.0 {
1333                    true => {
1334                        let mut m = [0.0; ROUTES];
1335                        m[to] = 1.0;
1336                        m
1337                    }
1338                    false => b,
1339                }
1340            };
1341            if labels.iter().any(|l| moved(l) != base(l)) {
1342                rows.push(doubly_robust(labels, &past, &ss, &moved, &base, &format!("fixed, grade {grade} {name}")));
1343            }
1344        }
1345    }
1346    let units = rows[0].dr.n;
1347    P5 {
1348        verdict: if units == 0 { Verdict::NotTested } else { Verdict::Reported },
1349        units,
1350        inconsistent: labels.iter().filter(|l| l.mu[l.route] <= 0.0).count(),
1351        rows,
1352    }
1353}
1354
1355/// One within-check contrast of two routes at one grade: per check with judged units of that grade on
1356/// both, the difference of their change rates; session bootstrap.
1357#[derive(Debug, Clone, PartialEq, Serialize)]
1358pub struct Contrast {
1359    pub grade: u8,
1360    pub routes: String,
1361    pub est: Estimate,
1362}
1363
1364#[derive(Debug, Clone, PartialEq, Serialize)]
1365pub struct Readout {
1366    pub due: bool,
1367    pub randomised: usize,
1368    pub sessions: usize,
1369    pub contrasts: Vec<Contrast>,
1370    /// The question-count test's own readout (rules v2, `test_readout`).
1371    pub test: TestReadout,
1372}
1373
1374/// The question-count test's readout (module doc), from the summary's fold (select/summary.rs
1375/// `Experiment`, `Check::xk`): its checks that drew a count (randomised), of them the control arm's,
1376/// those the gate refused (the fixed rule played) and the sessions with a drawn one, all-time; once
1377/// due (`test_due`: `TEST_CHECKS` in `TEST_MIN_SESSIONS` sessions, or `TEST_SESSIONS`), and only then, its estimates over the drawn checks the summary
1378/// still holds (`kept`: compaction drops the oldest checks, select/summary.rs `compact`, never the
1379/// counts), refused ones included, each with its session's bootstrap 95% interval (`ope::snips`).
1380/// Each a Horvitz–Thompson mean over every unit that could go either way, so a unit's outcome y·ω
1381/// (`unit_outcome`) counts even when it is 0: a verdict weighed by its inverse chance of one (policy.rs
1382/// `judged`, the monitor's), a lesson never delivered 0 (it changed nothing), one closed without a
1383/// verdict 0 (its route's judged ones stand in for it), one still open out until it closes.
1384/// - `by_asked`, dilution, per m ≥ 2: the test's top lesson (the first of select/policy.rs `tested`,
1385///   asked at every s ≥ 1) asked with m of the test's questions against asked alone, over the drawn
1386///   checks with k ≥ m: Σ (k + 1)·([s = m] − [s = 1])·y·ω / n. s is uniform in 0..=k, so the same
1387///   lesson's two draws differ in the number asked alone (grade mix, the 3s and k do not enter); by the
1388///   drawn s, a count the gate refused played as the fixed rule (intention to treat).
1389/// - `ask_free`, asking a 2 against handing it over: Σ([asked]·y·ω/p − [free]·y·ω/pf)/n over the 2s
1390///   that could go either way (0 < p, pf; the logged propensities are the draw's, a refused count's
1391///   routes the fixed rule's, select/policy.rs `choose`), a difference of change rates.
1392#[derive(Debug, Clone, PartialEq, Serialize)]
1393pub struct TestReadout {
1394    pub due: bool,
1395    pub checks: u64,
1396    pub control: u64,
1397    pub refused: u64,
1398    pub sessions: u32,
1399    /// The drawn checks the summary still holds, which the estimates read.
1400    pub kept: u64,
1401    pub by_asked: Vec<(u16, Estimate)>,
1402    pub ask_free: Option<Estimate>,
1403}
1404
1405/// The outcome of apply `i` of `s` as a Horvitz–Thompson term (`TestReadout`): a verdict's y over its
1406/// chance of one (`judged`, floored at 1/`MAX_IPW` as the monitor floors it), 0 for a lesson never
1407/// delivered (dropped: outside `judged`'s fit, as policy.rs `model_route` leaves it) or closed without
1408/// a verdict, None while open.
1409fn unit_outcome(s: &Summary, judged: &HashMap<usize, f64>, i: usize) -> Option<f64> {
1410    use summary::{Delivered, Label};
1411    let a = s.applies.get(i)?;
1412    let never = a.delivered == Delivered::Dropped;
1413    let y = match a.label {
1414        Label::Changed => 1.0,
1415        // Nothing in the work: no change on the lesson's point.
1416        Label::Ignored | Label::NotArisen => 0.0,
1417        _ if never => return Some(0.0),
1418        Label::Open => return None,
1419        Label::Unclear | Label::Remains | Label::Censored => return Some(0.0),
1420    };
1421    // A lesson never delivered is known to be so: its verdict, if any, stands for itself.
1422    let p = if never { 1.0 } else { judged.get(&i).copied().unwrap_or(1.0).max(1.0 / policy::MAX_IPW) };
1423    Some(y / p)
1424}
1425
1426/// The question-count test's readout of `s` (`TestReadout`): what `borg debug select` reports, and
1427/// what the monitoring can show from the summary it loads.
1428#[must_use]
1429pub fn test_readout(s: &Summary) -> TestReadout {
1430    let e = &s.experiment;
1431    let (checks, refused) = e.totals();
1432    let due = test_due(checks, e.sessions);
1433    let kept = s.checks.iter().filter(|c| c.xs.is_some()).count() as u64;
1434    let mut out = TestReadout { due, checks, control: e.control, refused, sessions: e.sessions, kept, by_asked: Vec::new(), ask_free: None };
1435    if !due {
1436        return out;
1437    }
1438    let units = test_units(s);
1439    out.by_asked = units.by.into_iter().map(|(m, units)| (m, ope::snips(&units, ope::SEED))).collect();
1440    out.ask_free = Some(ope::snips(&units.both, ope::SEED));
1441    out
1442}
1443
1444/// Whether the question-count test's readout is due after `checks` drawn checks in `sessions`
1445/// sessions with one: `TEST_CHECKS` in `TEST_MIN_SESSIONS`, or `TEST_SESSIONS` alone.
1446#[must_use]
1447pub fn test_due(checks: u64, sessions: u32) -> bool {
1448    checks >= TEST_CHECKS && sessions >= TEST_MIN_SESSIONS || sessions >= TEST_SESSIONS
1449}
1450
1451/// The question-count test's Horvitz–Thompson units (`TestReadout`), as (1, term, session tick): per
1452/// m ≥ 2 the dilution's (`by`), and asking a 2 against handing it over (`both`).
1453struct TestUnits {
1454    by: BTreeMap<u16, Vec<(f64, f64, usize)>>,
1455    both: Vec<(f64, f64, usize)>,
1456}
1457
1458fn test_units(s: &Summary) -> TestUnits {
1459    let drawn: Vec<&summary::Check> = s.checks.iter().filter(|c| c.xs.is_some()).collect();
1460    let judged = policy::judged(s);
1461    let mut by: BTreeMap<u16, Vec<(f64, f64, usize)>> = BTreeMap::new();
1462    let mut both: Vec<(f64, f64, usize)> = Vec::new();
1463    for c in drawn {
1464        let session = c.tick as usize;
1465        let first = c.first as usize;
1466        let applies = s.applies.get(first..first + usize::from(c.n)).unwrap_or_default();
1467        for (j, a) in applies.iter().enumerate() {
1468            if a.grade != 2 || a.p <= 0.0 || a.pf <= 0.0 {
1469                continue;
1470            }
1471            let Some(y) = unit_outcome(s, &judged, first + j) else { continue };
1472            let r = match a.route {
1473                Route::Ask => y / a.p,
1474                Route::Free => -y / a.pf,
1475                _ => 0.0,
1476            };
1477            both.push((1.0, r, session));
1478        }
1479        // The test's lessons as its decision ordered them: the fixed rule's for what the session had
1480        // (`nofree`, the `had` the decision read), as select/policy.rs `choose` calls them.
1481        let units: Vec<Unit> = applies
1482            .iter()
1483            .map(|a| Unit { lesson: s.lessons.get(a.lesson as usize).map_or("", String::as_str), grade: a.grade, rank: usize::from(a.rank) })
1484            .collect();
1485        let had: Vec<String> = applies.iter().filter(|a| a.nofree).filter_map(|a| s.lessons.get(a.lesson as usize).cloned()).collect();
1486        let order = policy::tested(&units, &policy::fixed_routes(&units, &had), &had);
1487        let (Some(k), Some(drew), Some(&top)) = (c.xk, c.xs, order.first()) else { continue };
1488        // A check whose applies do not give its logged k is no draw over them.
1489        if order.len() != k as usize {
1490            continue;
1491        }
1492        let Some(y) = unit_outcome(s, &judged, first + top) else { continue };
1493        for m in 2..=k {
1494            let Ok(key) = u16::try_from(m) else { break };
1495            let r = (f64::from(k) + 1.0) * (f64::from(u8::from(drew == m)) - f64::from(u8::from(drew == 1))) * y;
1496            by.entry(key).or_default().push((1.0, r, session));
1497        }
1498    }
1499    TestUnits { by, both }
1500}
1501
1502// --- The question-count test pooled over stores (amendment 2026-10-31) ----------------------------
1503
1504/// The checks that drew one count s of the question-count test (`drawn`), and of them those that
1505/// played it; the gate refused the rest (source `GATE`: the fixed rule played). The readout is by the
1506/// drawn count (intention to treat), so this says how far each arm was the treatment it names.
1507#[derive(Debug, Clone, Default, PartialEq, Serialize)]
1508pub struct Played {
1509    pub drawn: u32,
1510    pub checks: u64,
1511    pub played: u64,
1512}
1513
1514/// One store of a pooled readout: its collective, its graded records imported from another log
1515/// (`imported`, as P1 counts them), left out of everything else here (the amendment reads no copied
1516/// history), its own readout (its counts and its own due; its estimates whenever the pool is due, so the
1517/// collectives can be compared), and the share played per drawn count.
1518#[derive(Debug, Clone, PartialEq, Serialize)]
1519pub struct Stratum {
1520    pub collective: String,
1521    pub imported: usize,
1522    pub readout: TestReadout,
1523    pub played: Vec<Played>,
1524}
1525
1526/// The question-count test's readout over several stores (amendment 2026-10-31, `TEST_AMENDMENT`),
1527/// stratified by collective: the checks summed and the sessions counted once each however many
1528/// collectives drew in them (`pooled`), due by `test_due` over them; once due, each estimate
1529/// of `TestReadout` pooled over every collective's units, its interval a session bootstrap that
1530/// resamples each collective's sessions within it (`stratified`); the share played per drawn count;
1531/// and, per estimate, each pair of collectives whose estimates differ beyond their joint 95% interval
1532/// (`differ`), where the pooled one is not to be read: per collective only.
1533#[derive(Debug, Clone, PartialEq, Serialize)]
1534pub struct Pooled {
1535    pub due: bool,
1536    pub checks: u64,
1537    pub sessions: u32,
1538    pub strata: Vec<Stratum>,
1539    pub by_asked: Vec<(u16, Estimate)>,
1540    pub ask_free: Option<Estimate>,
1541    pub played: Vec<Played>,
1542    pub differ: Vec<String>,
1543}
1544
1545/// The share played per drawn count of the graded records of `text`.
1546fn played_of(text: &str) -> Vec<Played> {
1547    let mut by: BTreeMap<u32, (u64, u64)> = BTreeMap::new();
1548    for o in lines(text).into_iter().filter_map(|l| l.o).filter(|o| o.kind == outcome::GRADED) {
1549        let Some(s) = o.xs else { continue };
1550        let e = by.entry(s).or_default();
1551        e.0 += 1;
1552        e.1 += u64::from(o.source != policy::GATE);
1553    }
1554    by.into_iter().map(|(drawn, (checks, played))| Played { drawn, checks, played }).collect()
1555}
1556
1557/// The ratio Σwr/Σw over every stratum's units (w, r, session), its 95% interval the 2.5th and 97.5th
1558/// percentiles of `ope::RESAMPLES` resamples that draw each stratum's sessions within it (a stratified
1559/// session bootstrap, deterministic from `seed`), as `ope::snips` resamples one stratum's.
1560fn stratified(strata: &[&[(f64, f64, usize)]], seed: u64) -> Estimate {
1561    let all: Vec<(f64, f64, usize)> = strata.iter().flat_map(|s| s.iter().copied()).collect();
1562    let one = ope::snips(&all, seed);
1563    let groups: Vec<Vec<(f64, f64)>> = strata
1564        .iter()
1565        .map(|s| {
1566            let mut by: BTreeMap<usize, (f64, f64)> = BTreeMap::new();
1567            for (w, r, x) in *s {
1568                let e = by.entry(*x).or_default();
1569                e.0 += w;
1570                e.1 += w * r;
1571            }
1572            by.into_values().collect()
1573        })
1574        .collect();
1575    let mut rng = memory::Rng::new(seed);
1576    let mut values: Vec<f64> = Vec::new();
1577    if one.value.is_some() {
1578        for _ in 0..ope::RESAMPLES {
1579            let (mut sw, mut swr) = (0.0, 0.0);
1580            for g in groups.iter().filter(|g| !g.is_empty()) {
1581                for _ in 0..g.len() {
1582                    let x = g[(rng.next_u64() % g.len() as u64) as usize];
1583                    sw += x.0;
1584                    swr += x.1;
1585                }
1586            }
1587            if sw > 0.0 {
1588                values.push(swr / sw);
1589            }
1590        }
1591    }
1592    values.sort_by(f64::total_cmp);
1593    let pct = |q: f64| values.get((q * (values.len().max(1) - 1) as f64).round() as usize).copied().unwrap_or(0.0);
1594    Estimate { lo: pct(0.025), hi: pct(0.975), ..one }
1595}
1596
1597/// Where two collectives' estimates of `what` differ beyond their joint 95% interval: each interval
1598/// read as ±`Z95` standard errors, the difference's as √(se₁² + se₂²); one line per such pair.
1599fn differences(what: &str, each: &[(&str, &Estimate)]) -> Vec<String> {
1600    let se = |e: &Estimate| (e.hi - e.lo) / (2.0 * Z95);
1601    let mut out = Vec::new();
1602    for (i, (a, x)) in each.iter().enumerate() {
1603        for (b, y) in &each[i + 1..] {
1604            let (Some(u), Some(v)) = (x.value, y.value) else { continue };
1605            let joint = Z95 * (se(x).powi(2) + se(y).powi(2)).sqrt();
1606            if (u - v).abs() > joint {
1607                out.push(format!("{what}: {a} {u:+.3} and {b} {v:+.3} differ beyond their joint interval (±{joint:.3}): read per collective only"));
1608            }
1609        }
1610    }
1611    out
1612}
1613
1614/// The question-count test's readout over `stores` (collective, outcome log text), stratified by
1615/// collective (`Pooled`). Each store's summary is its log folded, as `borg debug select --file` folds it,
1616/// without the graded records imported from another log (`imported`): their checks, and a session only
1617/// they hold, count toward nothing, neither the due rule nor an estimate. The pooled sessions are
1618/// distinct sessions: one drone session that drew a count in two collectives (it worked in both
1619/// stores' repos) is one session toward `TEST_MIN_SESSIONS` and `TEST_SESSIONS`, read off the session
1620/// tables (`Session::tested`); a session a store's compaction took off its table counts as that store
1621/// counted it.
1622#[must_use]
1623pub fn pooled(stores: &[(String, String)]) -> Pooled {
1624    let parts: Vec<(Stratum, TestUnits, Vec<String>)> = stores
1625        .iter()
1626        .map(|(collective, text)| {
1627            let all = lines(text);
1628            let copied = |l: &Line| l.o.as_ref().is_some_and(|o| o.kind == outcome::GRADED && imported(o, text));
1629            let imported = all.iter().filter(|l| copied(l)).count();
1630            let own: String = all.iter().filter(|l| !copied(l)).map(|l| l.text).collect();
1631            let s = summary::fold(&own, Summary::default());
1632            let stratum = Stratum { collective: collective.clone(), imported, readout: test_readout(&s), played: played_of(&own) };
1633            let tested = s.sessions.iter().filter(|x| x.tested).map(|x| x.id.clone()).collect();
1634            (stratum, test_units(&s), tested)
1635        })
1636        .collect();
1637    let checks = parts.iter().map(|(s, _, _)| s.readout.checks).sum();
1638    let mut distinct: BTreeSet<&str> = BTreeSet::new();
1639    let mut compacted = 0u32;
1640    for (s, _, tested) in &parts {
1641        distinct.extend(tested.iter().map(String::as_str));
1642        compacted += s.readout.sessions.saturating_sub(u32::try_from(tested.len()).unwrap_or(u32::MAX));
1643    }
1644    let sessions = u32::try_from(distinct.len()).unwrap_or(u32::MAX).saturating_add(compacted);
1645    let mut played: BTreeMap<u32, (u64, u64)> = BTreeMap::new();
1646    for p in parts.iter().flat_map(|(s, _, _)| &s.played) {
1647        let e = played.entry(p.drawn).or_default();
1648        e.0 += p.checks;
1649        e.1 += p.played;
1650    }
1651    let mut out = Pooled {
1652        due: test_due(checks, sessions),
1653        checks,
1654        sessions,
1655        strata: Vec::new(),
1656        by_asked: Vec::new(),
1657        ask_free: None,
1658        played: played.into_iter().map(|(drawn, (checks, played))| Played { drawn, checks, played }).collect(),
1659        differ: Vec::new(),
1660    };
1661    if out.due {
1662        let both: Vec<&[(f64, f64, usize)]> = parts.iter().map(|(_, u, _)| u.both.as_slice()).collect();
1663        out.ask_free = Some(stratified(&both, ope::SEED));
1664        let ms: BTreeSet<u16> = parts.iter().flat_map(|(_, u, _)| u.by.keys().copied()).collect();
1665        for m in ms {
1666            let each: Vec<&[(f64, f64, usize)]> = parts.iter().map(|(_, u, _)| u.by.get(&m).map_or(&[][..], Vec::as_slice)).collect();
1667            out.by_asked.push((m, stratified(&each, ope::SEED)));
1668        }
1669    }
1670    for (mut stratum, units, _) in parts {
1671        if out.due {
1672            stratum.readout.by_asked = units.by.into_iter().map(|(m, u)| (m, ope::snips(&u, ope::SEED))).collect();
1673            stratum.readout.ask_free = Some(ope::snips(&units.both, ope::SEED));
1674        }
1675        out.strata.push(stratum);
1676    }
1677    if out.due {
1678        let each: Vec<(&str, &Estimate)> = out.strata.iter().filter_map(|s| Some((s.collective.as_str(), s.readout.ask_free.as_ref()?))).collect();
1679        out.differ.extend(differences("grade 2 asked − handed over", &each));
1680        for (m, _) in &out.by_asked {
1681            let each: Vec<(&str, &Estimate)> =
1682                out.strata.iter().filter_map(|s| Some((s.collective.as_str(), s.readout.by_asked.iter().find(|(k, _)| k == m).map(|(_, e)| e)?))).collect();
1683            out.differ.extend(differences(&format!("dilution, {m} asked − 1 asked"), &each));
1684        }
1685    }
1686    out
1687}
1688
1689/// The share played per drawn count as text: "s asked played/drawn".
1690fn played_line(played: &[Played]) -> String {
1691    if played.is_empty() {
1692        return "no count drawn".into();
1693    }
1694    let each: Vec<String> = played.iter().map(|p| format!("{} asked {}/{}", p.drawn, p.played, p.checks)).collect();
1695    format!("played per drawn count: {}", each.join(", "))
1696}
1697
1698/// The pooled readout as text (`borg debug select --replay --file A --file B …`).
1699#[must_use]
1700pub fn render_pooled(p: &Pooled) -> String {
1701    let mut out = format!(
1702        "question-count test readout (rules v2, amendment {TEST_AMENDMENT}), pooled over {} collectives, stratified by collective: {} ({} checks that drew a count in {} sessions with one, due at {TEST_CHECKS} checks in {TEST_MIN_SESSIONS} sessions or {TEST_SESSIONS} sessions)\n",
1703        p.strata.len(),
1704        if p.due { "due" } else { "not due" },
1705        p.checks,
1706        p.sessions
1707    );
1708    for s in &p.strata {
1709        let r = &s.readout;
1710        let imported = if s.imported > 0 { format!("; {} graded records imported from another store's log, left out", s.imported) } else { String::new() };
1711        out.push_str(&format!(
1712            "  {}: {} drawn in {} sessions ({} of them the control arm, {} refused by the gate){imported}; {}\n",
1713            s.collective,
1714            r.checks,
1715            r.sessions,
1716            r.control,
1717            r.refused,
1718            played_line(&s.played)
1719        ));
1720    }
1721    out.push_str(&format!("  pooled: {}\n", played_line(&p.played)));
1722    if !p.due {
1723        return out;
1724    }
1725    out.push_str(
1726        "  intention to treat: by the drawn count, a count the gate refused read as drawn (session 95% CI; pooled: resampled within each collective)\n",
1727    );
1728    let row = |out: &mut String, what: &str, unit: &str, each: Vec<(&str, Option<&Estimate>)>, pooled: Option<&Estimate>| {
1729        let shown = |e: Option<&Estimate>| e.map_or_else(|| "-".to_string(), |e| format!("{} over {} {unit}", ci(e, 1.0), e.n));
1730        let mut parts: Vec<String> = each.into_iter().map(|(c, e)| format!("{c} {}", shown(e))).collect();
1731        parts.push(format!("pooled {}", shown(pooled)));
1732        out.push_str(&format!("  {what}: {}\n", parts.join("; ")));
1733    };
1734    let each = p.strata.iter().map(|s| (s.collective.as_str(), s.readout.ask_free.as_ref())).collect();
1735    row(&mut out, "grade 2 asked − handed over", "2s", each, p.ask_free.as_ref());
1736    for (m, e) in &p.by_asked {
1737        let each = p.strata.iter().map(|s| (s.collective.as_str(), s.readout.by_asked.iter().find(|(k, _)| k == m).map(|(_, e)| e))).collect();
1738        row(&mut out, &format!("dilution, {m} asked − 1 asked"), "checks", each, Some(e));
1739    }
1740    for d in &p.differ {
1741        out.push_str(&format!("  {d}\n"));
1742    }
1743    out
1744}
1745
1746fn readout(checks: &[Check], labels: &[Label], s: &Summary) -> Readout {
1747    let randomised = labels.iter().filter(|l| l.mu[l.route] > 0.0 && l.mu[l.route] < 1.0).count();
1748    let learned: BTreeSet<&str> = checks.iter().filter(|c| outcome::policy_of(&c.o) == "learned").map(|c| c.o.session.as_str()).collect();
1749    let ss = sessions(labels);
1750    // (grade, check) -> per route (units, changed), and the check's session.
1751    type Cell = ([(f64, f64); ROUTES], usize);
1752    let mut cells: BTreeMap<(u8, usize), Cell> = BTreeMap::new();
1753    for (l, s) in labels.iter().zip(&ss) {
1754        let e = cells.entry((l.grade, l.check)).or_insert(([(0.0, 0.0); ROUTES], *s));
1755        e.0[l.route].0 += 1.0;
1756        e.0[l.route].1 += l.y;
1757    }
1758    let mut contrasts = Vec::new();
1759    for grade in 1..=3u8 {
1760        for (a, b) in [(ASK, FREE), (FREE, NOT)] {
1761            let units: Vec<(f64, f64, usize)> = cells
1762                .range((grade, 0)..=(grade, usize::MAX))
1763                .filter(|(_, (r, _))| r[a].0 > 0.0 && r[b].0 > 0.0)
1764                .map(|(_, (r, s))| (1.0, r[a].1 / r[a].0 - r[b].1 / r[b].0, *s))
1765                .collect();
1766            contrasts.push(Contrast { grade, routes: format!("{}−{}", ROUTE_NAMES[a], ROUTE_NAMES[b]), est: ope::snips(&units, ope::SEED) });
1767        }
1768    }
1769    Readout { due: randomised >= READOUT_UNITS || learned.len() >= READOUT_SESSIONS, randomised, sessions: learned.len(), contrasts, test: test_readout(s) }
1770}
1771
1772// --- P4: full-information recall from an evaluation -----------------------------------------------
1773
1774/// One graded check of an evaluation run: its session, channel, applies (lesson, grade) in the
1775/// model's order, and what its model call cost.
1776#[derive(Debug, Clone, PartialEq)]
1777struct EvalCheck {
1778    session: String,
1779    channel: &'static str,
1780    applies: Vec<(String, u8)>,
1781    usd: f64,
1782}
1783
1784#[derive(Debug, Clone, PartialEq)]
1785struct EvalRun {
1786    case: String,
1787    checks: Vec<EvalCheck>,
1788}
1789
1790/// A session's graded checks from its state file `ctx` (`#borg-graded n=<m> <id>:<grade>:<rank>,…`
1791/// after the call's `#borg-predict-cost`) and its cost log `cost`, whose `live usd=` line before a
1792/// check's `graded` line marks a live check. Without an aligned cost log, a check followed by a
1793/// `#borg-live` delivery is live.
1794fn eval_checks(session: &str, ctx: &str, cost: &str) -> Vec<EvalCheck> {
1795    let mut live = Vec::new();
1796    let mut seen = false;
1797    for l in cost.lines() {
1798        seen |= l.starts_with("live usd=");
1799        if l.starts_with("graded applied=") {
1800            live.push(seen);
1801            seen = false;
1802        }
1803    }
1804    let lines: Vec<&str> = ctx.lines().collect();
1805    let graded = lines.iter().filter(|l| l.starts_with("#borg-graded n=")).count();
1806    let mut out = Vec::new();
1807    let mut usd = 0.0;
1808    for (i, l) in lines.iter().enumerate() {
1809        if let Some(c) = l.strip_prefix("#borg-predict-cost ") {
1810            usd = c.trim().parse().unwrap_or(0.0);
1811            continue;
1812        }
1813        let Some(rest) = l.strip_prefix("#borg-graded n=") else { continue };
1814        let applies = rest
1815            .split_once(' ')
1816            .map_or("", |(_, items)| items.trim())
1817            .split(',')
1818            .filter_map(|item| {
1819                let mut parts = item.rsplitn(3, ':');
1820                let (rank, grade, id) = (parts.next()?, parts.next()?, parts.next()?);
1821                rank.parse::<usize>().ok()?;
1822                Some((id.to_string(), grade.parse().ok()?))
1823            })
1824            .collect();
1825        let is_live = match live.len() == graded {
1826            true => live[out.len()],
1827            false => lines[i + 1..]
1828                .iter()
1829                .take_while(|x| !x.starts_with("#borg-graded ") && !x.starts_with("#borg-predict-cost "))
1830                .any(|x| x.starts_with("#borg-live ")),
1831        };
1832        out.push(EvalCheck { session: session.into(), channel: if is_live { "live" } else { "stop" }, applies, usd });
1833        usd = 0.0;
1834    }
1835    out
1836}
1837
1838fn sorted_dir(dir: &Path) -> Vec<PathBuf> {
1839    let mut v: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).collect();
1840    v.sort();
1841    v
1842}
1843
1844/// Every run under `dir`: a directory `<case>-s<seed>` holding `borg-state`, one or two levels down
1845/// (a run set's directory, or a directory of run sets).
1846fn eval_runs(dir: &Path) -> Vec<EvalRun> {
1847    let mut found = Vec::new();
1848    for d in sorted_dir(dir) {
1849        if d.join("borg-state").is_dir() {
1850            found.push(d);
1851        } else {
1852            found.extend(sorted_dir(&d).into_iter().filter(|r| r.join("borg-state").is_dir()));
1853        }
1854    }
1855    found
1856        .into_iter()
1857        .filter_map(|run| {
1858            let name = run.file_name()?.to_string_lossy().into_owned();
1859            let case = name.rsplit_once("-s").map_or(name.as_str(), |(c, _)| c).to_string();
1860            let mut checks = Vec::new();
1861            for f in sorted_dir(&run.join("borg-state")).into_iter().filter(|f| f.extension().is_some_and(|e| e == "ctx")) {
1862                let session = f.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
1863                let ctx = std::fs::read_to_string(&f).unwrap_or_default();
1864                let cost = std::fs::read_to_string(f.with_extension("cost")).unwrap_or_default();
1865                checks.extend(eval_checks(&session, &ctx, &cost));
1866            }
1867            Some(EvalRun { case, checks })
1868        })
1869        .collect()
1870}
1871
1872/// The lessons the qrels grade above 0, per case.
1873fn qrels(path: &Path) -> BTreeMap<String, BTreeSet<String>> {
1874    let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1875    for v in std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok()) {
1876        if let (Some(case), Some(id)) = (v["case"].as_str(), v["challenge"].as_str())
1877            && v["grade"].as_f64().unwrap_or(0.0) > 0.0
1878        {
1879            out.entry(case.into()).or_default().insert(id.into());
1880        }
1881    }
1882    out
1883}
1884
1885/// One policy's right-lesson recall (the share of runs in which a check delivers a right lesson,
1886/// expected over its draws), its change against the fixed rule (per case, case-paired bootstrap), per
1887/// check its expected questions, free deliveries and blocks at the stop, the checks at which a draw of
1888/// it routes otherwise than the fixed rule (with none it is the fixed rule, and its comparison tests
1889/// nothing) and the first of them, the runs whose recall equals the fixed rule's (`SAME`), and whether
1890/// it meets P4's recall bar (`RECALL_MARGIN`).
1891#[derive(Debug, Clone, PartialEq, Serialize)]
1892pub struct Arm {
1893    pub recall: f64,
1894    pub delta: Option<Estimate>,
1895    pub questions: f64,
1896    pub free: f64,
1897    pub blocks: f64,
1898    pub deviations: usize,
1899    pub first: Vec<String>,
1900    pub same: usize,
1901    pub pass: bool,
1902}
1903
1904/// One of P4's arms against its own bar (rules v2): P4a identity at n = 0, P4b recall under the
1905/// posterior, P4c the test keeps recall; `why` says what failed or why it tested nothing.
1906#[derive(Debug, Clone, PartialEq, Serialize)]
1907pub struct Part {
1908    pub verdict: Verdict,
1909    pub why: String,
1910    pub arm: Arm,
1911}
1912
1913#[derive(Debug, Clone, PartialEq, Serialize)]
1914pub struct P4 {
1915    pub verdict: Verdict,
1916    pub why: String,
1917    /// Cases with runs, of the cases the qrels name; runs and graded checks read.
1918    pub cases: usize,
1919    pub of: usize,
1920    pub runs: usize,
1921    pub checks: usize,
1922    /// Fewest and most runs (seeds) per case.
1923    pub seeds: (usize, usize),
1924    /// What a check's model call cost, on average: the same call under every policy.
1925    pub usd: f64,
1926    /// Of the checks, those that apply a lesson: P4a tests nothing without one, as every policy
1927    /// decides nothing there.
1928    pub applying: usize,
1929    /// The store's posterior P4b draws from (select/policy.rs `Fit::key`, empty for the prior) and the
1930    /// labels it read.
1931    pub fit: String,
1932    pub labels: u32,
1933    pub baseline: Option<Arm>,
1934    /// P4a, P4b and P4c (module doc); None until the evaluation is complete. `verdict` is theirs by
1935    /// the rules v2's default rule: PASS when P4a and P4c pass and P4b passes or is NOT TESTED, FAIL
1936    /// when one of them fails, NOT TESTED otherwise.
1937    pub p4a: Option<Part>,
1938    pub p4b: Option<Part>,
1939    pub p4c: Option<Part>,
1940}
1941
1942impl P4 {
1943    fn not_run(why: &str) -> Self {
1944        P4 {
1945            verdict: Verdict::NotRun,
1946            why: why.into(),
1947            cases: 0,
1948            of: 0,
1949            runs: 0,
1950            checks: 0,
1951            seeds: (0, 0),
1952            usd: 0.0,
1953            applying: 0,
1954            fit: String::new(),
1955            labels: 0,
1956            baseline: None,
1957            p4a: None,
1958            p4b: None,
1959            p4c: None,
1960        }
1961    }
1962}
1963
1964/// One draw of a policy's routes for a check's units, from a seed.
1965type Draw<'a> = dyn Fn(&[Unit], &EvalCheck, u64) -> Vec<Route> + 'a;
1966
1967/// What `recalls` measures of one policy on an evaluation's runs.
1968struct Recalls {
1969    /// Per run, the chance it delivers a right lesson at some check.
1970    per_run: Vec<f64>,
1971    /// Per check, expected questions, free deliveries and blocks at the stop.
1972    spend: [f64; 3],
1973    /// Checks at which some draw routes otherwise than the fixed rule, and the first `SHOW` of them.
1974    deviations: usize,
1975    first: Vec<String>,
1976}
1977
1978/// The policy `draw` on `runs` (`Recalls`). `draw` None is the fixed rule (select/policy.rs
1979/// `fixed_routes`: by position, P0's conscience.rs `graded_without` with no cap); otherwise `n` draws
1980/// of the policy seeded per check (a stand-in check id from the session and the check's place in it:
1981/// the run logged none), independent across checks. The evaluation's state lines log no `nofree`:
1982/// every check is decided with an empty had, as the hook that ran it decided (module doc).
1983fn recalls(runs: &[EvalRun], right: &BTreeMap<String, BTreeSet<String>>, draw: Option<&Draw>, n_draws: usize, lessons: &Path) -> Recalls {
1984    let none = BTreeSet::new();
1985    let prior = Summary::default();
1986    let (mut per_run, mut spend, mut n, mut deviations, mut first) = (Vec::new(), [0.0; 3], 0.0, 0, Vec::new());
1987    let names = |rs: &[Route]| rs.iter().map(|r| r.name()).collect::<Vec<_>>().join(",");
1988    for run in runs {
1989        let right = right.get(&run.case).unwrap_or(&none);
1990        let mut missed = 1.0;
1991        for (k, c) in run.checks.iter().enumerate() {
1992            let cs: Vec<Challenge> = c.applies.iter().map(|(id, _)| Challenge { id: id.clone(), ..Default::default() }).collect();
1993            let applied: Vec<(&Challenge, u8)> = cs.iter().zip(c.applies.iter().map(|(_, g)| *g)).collect();
1994            let units = super::units(&applied);
1995            let fixed = policy::fixed_routes(&units, &[]);
1996            let draws: Vec<Vec<Route>> = match draw {
1997                None => vec![fixed.clone()],
1998                Some(draw) => {
1999                    let check = super::check_id(&c.session, c.channel, k as i64, 0);
2000                    // Only the seed: the evaluation's state file logs no pool tokens.
2001                    let seed = super::seed(&Ctx {
2002                        session: &c.session,
2003                        check: &check,
2004                        channel: c.channel,
2005                        lessons,
2006                        summary: &prior,
2007                        tokens: 0,
2008                        had: &[],
2009                        alpha: policy::ALPHA,
2010                    });
2011                    let mut rng = memory::Rng::new(seed);
2012                    (0..n_draws.max(1)).map(|_| draw(&units, c, rng.next_u64())).collect()
2013                }
2014            };
2015            if let Some(off) = draws.iter().find(|d| **d != fixed) {
2016                deviations += 1;
2017                if first.len() < SHOW {
2018                    first.push(format!(
2019                        "case {} session {} check {k} ({}): {} against the fixed rule's {}",
2020                        run.case,
2021                        c.session,
2022                        c.channel,
2023                        names(off),
2024                        names(&fixed)
2025                    ));
2026                }
2027            }
2028            let share = 1.0 / draws.len() as f64;
2029            let mut hit = 0.0;
2030            for routes in &draws {
2031                let delivered = |j: usize| matches!(routes.get(j), Some(Route::Ask | Route::Free));
2032                hit += share * f64::from(u8::from((0..cs.len()).any(|j| delivered(j) && right.contains(&cs[j].id))));
2033                let asked = routes.iter().filter(|r| **r == Route::Ask).count();
2034                spend[0] += share * asked as f64;
2035                spend[1] += share * routes.iter().filter(|r| **r == Route::Free).count() as f64;
2036                spend[2] += share * f64::from(u8::from(c.channel == "stop" && asked > 0));
2037            }
2038            missed *= 1.0 - hit;
2039            n += 1.0;
2040        }
2041        per_run.push(1.0 - missed);
2042    }
2043    Recalls { per_run, spend: spend.map(|x| if n > 0.0 { x / n } else { 0.0 }), deviations, first }
2044}
2045
2046/// P4 on the runs under `dir` with the qrels at `qrels_path` (module doc): the decision rule without
2047/// the test under the n = 0 prior (select/policy.rs `prior`, P4a) and under `posterior`, the store's
2048/// fit (`evaluate`, P4b), and the test ungated (P4c).
2049fn recall(dir: &Path, qrels_path: &Path, posterior: &Fit, lessons: &Path) -> P4 {
2050    let right = qrels(qrels_path);
2051    let runs = eval_runs(dir);
2052    if right.is_empty() {
2053        return P4::not_run(&format!("no qrels in {}", qrels_path.display()));
2054    }
2055    if runs.is_empty() {
2056        return P4::not_run(&format!("no runs with graded lines under {}", dir.display()));
2057    }
2058    let prior = policy::prior();
2059    let p4 = against_baseline(
2060        &runs,
2061        &right,
2062        Arms { prior: &|u, c, _| evidence(u, c, &prior), posterior: &|u, c, _| evidence(u, c, posterior), test: &|u, c, s| tested(u, c, &prior, s) },
2063        lessons,
2064    );
2065    P4 { fit: posterior.key.clone(), labels: posterior.labels, ..p4 }
2066}
2067
2068/// P4's three arms (module doc): the decision rule without the test under the n = 0 prior (P4a) and
2069/// under the store's posterior (P4b), each deterministic (one draw per check), and the question-count
2070/// test ungated (P4c), drawn `conscience::P_DRAWS` times per check.
2071struct Arms<'a> {
2072    prior: &'a Draw<'a>,
2073    posterior: &'a Draw<'a>,
2074    test: &'a Draw<'a>,
2075}
2076
2077/// P4's comparison of its three arms (`Arms`) with the fixed rule on `runs`, each against its bar.
2078fn against_baseline(runs: &[EvalRun], right: &BTreeMap<String, BTreeSet<String>>, arms: Arms, lessons: &Path) -> P4 {
2079    let mut per_case: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
2080    for (i, r) in runs.iter().enumerate() {
2081        per_case.entry(r.case.as_str()).or_default().push(i);
2082    }
2083    let checks: Vec<&EvalCheck> = runs.iter().flat_map(|r| &r.checks).collect();
2084    let seeds = per_case.values().map(Vec::len);
2085    let mut p4 = P4 {
2086        verdict: Verdict::NotRun,
2087        why: String::new(),
2088        cases: right.keys().filter(|c| per_case.contains_key(c.as_str())).count(),
2089        of: right.len(),
2090        runs: runs.len(),
2091        checks: checks.len(),
2092        seeds: (seeds.clone().min().unwrap_or(0), seeds.max().unwrap_or(0)),
2093        usd: if checks.is_empty() { 0.0 } else { checks.iter().map(|c| c.usd).sum::<f64>() / checks.len() as f64 },
2094        applying: checks.iter().filter(|c| !c.applies.is_empty()).count(),
2095        fit: String::new(),
2096        labels: 0,
2097        baseline: None,
2098        p4a: None,
2099        p4b: None,
2100        p4c: None,
2101    };
2102    let base = recalls(runs, right, None, 1, lessons);
2103    let case_mean = |v: &[f64], idx: &[usize]| idx.iter().map(|&i| v[i]).sum::<f64>() / idx.len() as f64;
2104    let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len().max(1) as f64;
2105    let arm = |draw: &Draw, n: usize| {
2106        let rec = recalls(runs, right, Some(draw), n, lessons);
2107        let units: Vec<(f64, f64, usize)> =
2108            per_case.values().enumerate().map(|(k, idx)| (1.0, case_mean(&rec.per_run, idx) - case_mean(&base.per_run, idx), k)).collect();
2109        let delta = ope::snips(&units, ope::SEED);
2110        let pass = delta.value.is_some_and(|v| v >= -RECALL_MARGIN) && delta.lo > -RECALL_MARGIN;
2111        let [questions, free, blocks] = rec.spend;
2112        let same = rec.per_run.iter().zip(&base.per_run).filter(|(a, b)| (*a - *b).abs() <= SAME).count();
2113        Arm { recall: mean(&rec.per_run), delta: Some(delta), questions, free, blocks, deviations: rec.deviations, first: rec.first, same, pass }
2114    };
2115    // The decision rule draws nothing of its own (select/policy.rs `decide_on_evidence`): one draw is
2116    // its expectation. The test draws its count from the seed: `conscience::P_DRAWS` of them.
2117    let (prior, post, test) = (arm(arms.prior, 1), arm(arms.posterior, 1), arm(arms.test, conscience::P_DRAWS));
2118    // The pre-registered design is every case at the same seeds: a run still going, or a seed that
2119    // failed, leaves it incomplete.
2120    p4.verdict = match (p4.cases == p4.of, p4.seeds.0 == p4.seeds.1) {
2121        (true, true) => {
2122            let (applying, runs) = (p4.applying, p4.runs);
2123            // P4a: 0 differences from the fixed rule under the n = 0 prior.
2124            let verdict = Verdict::tested(prior.deviations == 0, applying > 0);
2125            let why = match verdict {
2126                Verdict::Fail => format!("{} of {applying} checks that apply a lesson decide otherwise than the fixed rule", prior.deviations),
2127                Verdict::NotTested => "no check applies a lesson".to_string(),
2128                _ => String::new(),
2129            };
2130            let a = Part { verdict, why, arm: prior };
2131            // P4b: P4's recall bar; NOT TESTED where it is the fixed rule.
2132            let (verdict, why) = match (post.deviations, post.pass) {
2133                (0, _) => (Verdict::NotTested, "it decides as the fixed rule at every check, so it is the fixed rule".to_string()),
2134                (_, true) => (Verdict::Pass, String::new()),
2135                (_, false) => (Verdict::Fail, format!("recall Δ {} points misses the bar", post.delta.as_ref().map_or("-".into(), |d| ci(d, 100.0)))),
2136            };
2137            let b = Part { verdict, why, arm: post };
2138            // P4c: every run's recall the fixed rule's, tested only where a draw leaves it.
2139            let verdict = Verdict::tested(test.same == runs, test.deviations > 0);
2140            let why = match verdict {
2141                Verdict::Fail => format!("{} of {runs} runs recall otherwise than the fixed rule", runs - test.same),
2142                Verdict::NotTested => "no draw leaves the fixed rule: no live check has a lesson the test may hand over or ask".to_string(),
2143                _ => String::new(),
2144            };
2145            let c = Part { verdict, why, arm: test };
2146            let unmet: Vec<String> = [("P4a", &a), ("P4b", &b), ("P4c", &c)]
2147                .iter()
2148                .filter(|(n, p)| p.verdict != Verdict::Pass && !(*n == "P4b" && p.verdict == Verdict::NotTested))
2149                .map(|(n, p)| format!("{n} {}", p.verdict.label()))
2150                .collect();
2151            let verdict = match unmet.is_empty() {
2152                true => Verdict::Pass,
2153                false if [&a, &b, &c].iter().any(|p| p.verdict == Verdict::Fail) => Verdict::Fail,
2154                false => Verdict::NotTested,
2155            };
2156            p4.why = unmet.join(", ");
2157            (p4.p4a, p4.p4b, p4.p4c) = (Some(a), Some(b), Some(c));
2158            verdict
2159        }
2160        (false, _) => {
2161            p4.why = format!("runs cover {} of the {} cases the qrels name", p4.cases, p4.of);
2162            Verdict::NotRun
2163        }
2164        (true, false) => {
2165            p4.why = format!("cases have {} to {} runs: the evaluation is incomplete", p4.seeds.0, p4.seeds.1);
2166            Verdict::NotRun
2167        }
2168    };
2169    let [questions, free, blocks] = base.spend;
2170    p4.baseline =
2171        Some(Arm { recall: mean(&base.per_run), delta: None, questions, free, blocks, deviations: 0, first: Vec::new(), same: runs.len(), pass: true });
2172    p4
2173}
2174
2175// --- The report -----------------------------------------------------------------------------------
2176
2177#[derive(Debug, Clone, PartialEq, Serialize)]
2178pub struct Checks {
2179    pub p0: P0,
2180    pub p1: P1,
2181    pub p2: P2,
2182    pub p3: P3,
2183    pub p4: P4,
2184    pub p5: P5,
2185    pub readout: Readout,
2186}
2187
2188#[derive(Debug, Clone, PartialEq, Serialize)]
2189pub struct Report {
2190    pub rules: &'static str,
2191    pub amendment: &'static str,
2192    pub rules_v2: &'static str,
2193    /// The policy this process would decide under (select.rs `policy`). The checks evaluate the
2194    /// learned policy (and P1 each record's logged policy) whatever it is.
2195    pub policy: &'static str,
2196    /// The store's selection summary as a hook would load it (select/summary.rs `load`), or rebuilt.
2197    pub summary: Summary,
2198    pub records: usize,
2199    pub graded: usize,
2200    /// Graded records whose grades are not aligned with their lessons: folded, never replayed.
2201    pub malformed: usize,
2202    /// Graded checks per policy (outcome.rs `policy_of`).
2203    pub policies: BTreeMap<String, usize>,
2204    pub checks: Option<Checks>,
2205}
2206
2207/// What to evaluate: the outcome log's text, the lessons file beside it, the summary to show, whether
2208/// to run the checks, and an evaluation for P4.
2209pub struct Input<'a> {
2210    pub text: &'a str,
2211    pub lessons: &'a Path,
2212    pub summary: Summary,
2213    pub replay: bool,
2214    pub eval: Option<(&'a Path, &'a Path)>,
2215}
2216
2217/// The state and, with `replay`, the checks P0–P5 and the readout.
2218pub fn evaluate(input: Input) -> Report {
2219    let log: Vec<Outcome> = lines(input.text).into_iter().filter_map(|l| l.o).collect();
2220    let mut policies: BTreeMap<String, usize> = BTreeMap::new();
2221    for o in log.iter().filter(|o| o.kind == outcome::GRADED) {
2222        *policies.entry(outcome::policy_of(o).to_string()).or_default() += 1;
2223    }
2224    let mut report = Report {
2225        rules: RULES,
2226        amendment: AMENDMENT,
2227        rules_v2: RULES_V2,
2228        policy: super::policy().name(),
2229        summary: input.summary,
2230        records: log.len(),
2231        graded: policies.values().sum(),
2232        malformed: 0,
2233        policies,
2234        checks: None,
2235    };
2236    if !input.replay {
2237        report.malformed = log.iter().filter(|o| o.kind == outcome::GRADED && challenges(o).is_none()).count();
2238        return report;
2239    }
2240    let pass = as_logged(input.text, input.lessons);
2241    let budget = super::budget_of(input.lessons);
2242    let (p3, targets) = cold_start(input.text, input.lessons, budget);
2243    let (labels, unjoined) = labels(&pass.checks, &log);
2244    // The store's posterior: the whole log fitted as `borg learn` fits it (summary.rs `refit`), never
2245    // the folded summary's own fit, which a fold leaves at whatever it started from.
2246    let p4 = match input.eval {
2247        Some((dir, q)) => recall(dir, q, &policy::fit(&pass.posterior, budget), input.lessons),
2248        None => P4::not_run("pass --eval RUNS_DIR --qrels FILE"),
2249    };
2250    report.malformed = pass.malformed;
2251    report.checks = Some(Checks {
2252        p2: calibration(&labels, unjoined, &|value, past, at| model(&pass.checks, past, at, value)),
2253        p5: off_policy(&pass.checks, &labels, &targets),
2254        readout: readout(&pass.checks, &labels, &pass.posterior),
2255        p0: pass.p0,
2256        p1: pass.p1,
2257        p3,
2258        p4,
2259    });
2260    report
2261}
2262
2263/// A decisive check that failed or tested nothing (`Verdict::NotTested`), by the rules v2's default
2264/// rule: P0–P3, and P4 (`P4::verdict`: P4a and P4c, and P4b failed; P4b NOT TESTED is the fixed rule
2265/// and meets it). The command then exits 1. One not run (P4 without an evaluation) is no verdict
2266/// either way.
2267pub fn unmet(r: &Report) -> bool {
2268    r.checks
2269        .as_ref()
2270        .is_some_and(|c| [c.p0.verdict, c.p1.verdict, c.p2.verdict, c.p3.verdict, c.p4.verdict].iter().any(|v| matches!(v, Verdict::Fail | Verdict::NotTested)))
2271}
2272
2273fn ci(e: &Estimate, scale: f64) -> String {
2274    match e.value {
2275        Some(v) => format!("{:+.3} [{:+.3}, {:+.3}]", v * scale, e.lo * scale, e.hi * scale),
2276        None => "-".into(),
2277    }
2278}
2279
2280fn spend(s: &Spend) -> String {
2281    let tokens = if s.tokens > 0 { format!("{} pool tokens", s.tokens) } else { "pool tokens not logged".into() };
2282    let usd = if s.usd > 0.0 { format!(", ${:.2} logged", s.usd) } else { String::new() };
2283    format!("{} checks, {} questions, {} blocks at the stop, {} free, {tokens}{usd}", s.checks, s.questions, s.blocks, s.free)
2284}
2285
2286fn arm(a: &Arm) -> String {
2287    let delta = a.delta.as_ref().map_or(String::new(), |d| format!(", Δ {} points (case-paired 95% CI)", ci(d, 100.0)));
2288    format!("recall {:.1}%{delta}; per check {:.2} questions, {:.2} free, {:.2} blocks at the stop", 100.0 * a.recall, a.questions, a.free, a.blocks)
2289}
2290
2291/// The report as text: the state, then each check with its verdict against its bar.
2292pub fn render(r: &Report) -> String {
2293    let fit = if r.summary.fit.key.is_empty() { "the prior" } else { r.summary.fit.key.as_str() };
2294    let by: Vec<String> = r.policies.iter().map(|(p, n)| format!("{n} {p}")).collect();
2295    let mut out = format!(
2296        "rules {}, amendment {}, rules v2 {} ({RULES_AT}: pre-registered checks)\npolicy: {}{}; summary v{} at byte {}, fit {fit}\nrecords: {}, graded checks: {} ({}){}\n",
2297        r.rules,
2298        r.amendment,
2299        r.rules_v2,
2300        r.policy,
2301        if r.checks.is_some() { " here, the checks replay the learned policy" } else { "" },
2302        r.summary.sv,
2303        r.summary.at,
2304        r.records,
2305        r.graded,
2306        if by.is_empty() { "none".to_string() } else { by.join(", ") },
2307        if r.malformed > 0 { format!(", {} malformed", r.malformed) } else { String::new() }
2308    );
2309    let Some(c) = &r.checks else {
2310        out.push_str("run with --replay for the pre-registered checks P0–P5\n");
2311        return out;
2312    };
2313    let list = |out: &mut String, first: &[String]| {
2314        for f in first {
2315            out.push_str(&format!("  {f}\n"));
2316        }
2317    };
2318    let empty = |n: usize, tested: &str| if n > 0 { format!("; {n} of them apply no lesson and test {tested}") } else { String::new() };
2319    out.push_str(&format!(
2320        "P0 baseline identity: {}: {} of {} checks at gate budget 0 decide as the fixed rule (conscience::graded_without) (bar: every check){}\n",
2321        c.p0.verdict.label(),
2322        c.p0.checks - c.p0.differ,
2323        c.p0.checks,
2324        empty(c.p0.empty, "nothing")
2325    ));
2326    list(&mut out, &c.p0.first);
2327    out.push_str(&format!(
2328        "P1 determinism: {}: {} of {} replayed checks reproduce their decision and propensities within {} (max |Δ| {:.4}), {} from the summary their record names{}{}{}; {} under an evaluation switch not replayed; incremental fold {} the whole fold\n",
2329        c.p1.verdict.label(),
2330        c.p1.reproduced,
2331        c.p1.replayed,
2332        ope::AGREE,
2333        c.p1.max_delta,
2334        c.p1.seen,
2335        empty(c.p1.empty, "only the summary and source they name"),
2336        if c.p1.tests > 0 { format!("; {} of them the question-count test's, its draw replayed", c.p1.tests) } else { String::new() },
2337        if c.p1.imported > 0 {
2338            format!("; {} imported from another store's log (the summary or fit they name is no line boundary of this one), skipped", c.p1.imported)
2339        } else {
2340            String::new()
2341        },
2342        c.p1.switches,
2343        if c.p1.fold { "=" } else { "≠" }
2344    ));
2345    list(&mut out, &c.p1.first);
2346    let p2 = &c.p2;
2347    let by = if p2.verdict == Verdict::Pass && p2.pruned.is_some() { " by the pruned model" } else { "" };
2348    out.push_str(&format!("P2 calibration: {}{by}", p2.verdict.label()));
2349    if !p2.why.is_empty() {
2350        out.push_str(&format!(": {}", p2.why));
2351    }
2352    match (&p2.model, &p2.diff) {
2353        (Some(m), Some(d)) => out.push_str(&format!(
2354            ": leave-future-out log-loss model {m:.4} vs grade only {:.4}, mean difference {} (bar: upper bound ≤ {CALIBRATION})",
2355            p2.grade_only,
2356            ci(d, 1.0)
2357        )),
2358        _ => out.push_str(&format!("; grade-only leave-future-out log-loss {:.4}", p2.grade_only)),
2359    }
2360    out.push_str(&format!(
2361        " over {} judged units in {} sessions, {} with a label before their check ({} verdicts join no check)\n",
2362        p2.labels, p2.sessions, p2.informed, p2.unjoined
2363    ));
2364    if let Some(r) = &p2.pruned {
2365        out.push_str(&format!(
2366            "  pre-registered rerun, the model pruned to grade, route and dilution: log-loss {:.4} vs grade only {:.4}, mean difference {}: {}{}\n",
2367            r.model,
2368            p2.grade_only,
2369            ci(&r.diff, 1.0),
2370            r.verdict.label(),
2371            match r.verdict {
2372                Verdict::Fail => ": a second miss fails P2",
2373                Verdict::Pass => ": P2 passes on the pruned model",
2374                _ => "",
2375            }
2376        ));
2377    }
2378    let p3 = &c.p3;
2379    out.push_str(&format!(
2380        "P3 gate conduct: {}{}: cold-start replay of {} checks ({} refits, one after each session end), {} deviations from the fixed rule, first check {}, {} over the gate's budget; {}\n",
2381        p3.verdict.label(),
2382        if p3.why.is_empty() { String::new() } else { format!(" ({})", p3.why) },
2383        p3.checks,
2384        p3.refits,
2385        p3.deviations,
2386        if p3.cold { "the fixed rule" } else { "not the fixed rule" },
2387        p3.over,
2388        match p3.budget {
2389            Some(b) => format!(
2390                "the collective's budget of {b} pool tokens per session: the policy's pools spend {:.0} per session, {} it",
2391                p3.pool,
2392                if p3.pool <= b { "within" } else { "over" }
2393            ),
2394            None => format!("no budget set for the collective: spend reported, not gated; the policy's pools spend {:.0} tokens per session", p3.pool),
2395        }
2396    ));
2397    if !p3.sources.is_empty() {
2398        let by: Vec<String> = p3.sources.iter().map(|(s, n)| format!("{n} {s}")).collect();
2399        out.push_str(&format!("  decided by: {}\n", by.join(", ")));
2400    }
2401    out.push_str(&format!("  spend, policy: {}\n  spend, fixed rule: {}\n", spend(&p3.policy), spend(&p3.fixed)));
2402    list(&mut out, &p3.first);
2403    let p4 = &c.p4;
2404    out.push_str(&format!("P4 full-information recall: {}", p4.verdict.label()));
2405    if !p4.why.is_empty() {
2406        out.push_str(&format!(": {}", p4.why));
2407    }
2408    if let Some(b) = &p4.baseline {
2409        out.push_str(&format!(
2410            "\n  {} of {} cases, {} runs ({}–{} per case), {} checks ({} apply a lesson), ${:.4} per check (one model call under every policy)\n  fixed rule: {}",
2411            p4.cases,
2412            p4.of,
2413            p4.runs,
2414            p4.seeds.0,
2415            p4.seeds.1,
2416            p4.checks,
2417            p4.applying,
2418            p4.usd,
2419            arm(b)
2420        ));
2421    }
2422    let part = |out: &mut String, name: &str, bar: &str, p: &Part| {
2423        let why = if p.why.is_empty() { String::new() } else { format!(": {}", p.why) };
2424        out.push_str(&format!(
2425            "\n  {name}: {}{why} (bar: {bar})\n    {}; {} checks drawn off the fixed rule",
2426            p.verdict.label(),
2427            arm(&p.arm),
2428            p.arm.deviations
2429        ));
2430        for f in &p.arm.first {
2431            out.push_str(&format!("\n    {f}"));
2432        }
2433    };
2434    if let (Some(a), Some(b), Some(c)) = (&p4.p4a, &p4.p4b, &p4.p4c) {
2435        part(&mut out, "P4a identity at n = 0, the decision rule without the test under the n = 0 prior", "0 differences from the fixed rule", a);
2436        let bar = format!(
2437            "Δ ≥ −{0} points and lower bound > −{0}; NOT TESTED where it deviates nowhere; a pass on runs seen before is weaker evidence than a first-run pass",
2438            100.0 * RECALL_MARGIN
2439        );
2440        let name =
2441            format!("P4b recall under the store posterior (fit {}, {} labels)", if p4.fit.is_empty() { "the prior" } else { p4.fit.as_str() }, p4.labels);
2442        part(&mut out, &name, &bar, b);
2443        part(
2444            &mut out,
2445            &format!("P4c the test keeps recall, ungated on every live check ({} of {} runs recall as the fixed rule)", c.arm.same, p4.runs),
2446            "every run",
2447            c,
2448        );
2449    }
2450    out.push('\n');
2451    let p5 = &c.p5;
2452    out.push_str(&format!(
2453        "P5 off-policy: {}: {} judged units{}; change against the fixed rule per unit (DR, session 95% CI), the policy's own value (SNIPS), coverage\n",
2454        p5.verdict.label(),
2455        p5.units,
2456        if p5.inconsistent > 0 { format!(" ({} delivered by a route logged at p = 0, left out)", p5.inconsistent) } else { String::new() }
2457    ));
2458    for row in &p5.rows {
2459        out.push_str(&format!(
2460            "  {:<22} DR {:>26}  SNIPS {:>26}  coverage {:>5.1}%  ESS {:.1}\n",
2461            row.policy,
2462            ci(&row.dr, 1.0),
2463            ci(&row.ips, 1.0),
2464            100.0 * row.coverage,
2465            row.ips.ess
2466        ));
2467    }
2468    let ro = &c.readout;
2469    out.push_str(&format!(
2470        "readout: {} ({} of {READOUT_UNITS} randomised labelled units, {} of {READOUT_SESSIONS} sessions under the learned policy); the policy stays on unless the monitor fired\n",
2471        if ro.due { "due" } else { "not due" },
2472        ro.randomised,
2473        ro.sessions
2474    ));
2475    let shown: Vec<&Contrast> = ro.contrasts.iter().filter(|x| x.est.n > 0).collect();
2476    if shown.is_empty() {
2477        out.push_str("  no check has judged units of one grade on two routes yet\n");
2478    }
2479    for x in shown {
2480        out.push_str(&format!("  grade {} {}: {} over {} checks (within-check, session 95% CI)\n", x.grade, x.routes, ci(&x.est, 1.0), x.est.n));
2481    }
2482    let t = &ro.test;
2483    out.push_str(&format!(
2484        "question-count test readout (rules v2, amendment {TEST_AMENDMENT}): {} ({} checks that drew a count in {} sessions with one, due at {TEST_CHECKS} checks in {TEST_MIN_SESSIONS} sessions or {TEST_SESSIONS} sessions; {} of them the control arm, {} refused by the gate)\n",
2485        if t.due { "due" } else { "not due" },
2486        t.checks,
2487        t.sessions,
2488        t.control,
2489        t.refused
2490    ));
2491    if t.due {
2492        out.push_str(&format!("  estimated over the {} drawn checks the summary still holds (compaction drops the oldest, never the counts)\n", t.kept));
2493    }
2494    for (m, e) in &t.by_asked {
2495        out.push_str(&format!(
2496            "  dilution, {m} asked − 1 asked: the test's top lesson's change rate {} over {} checks with k ≥ {m} (Horvitz–Thompson by the uniform draw, session 95% CI)\n",
2497            ci(e, 1.0),
2498            e.n
2499        ));
2500    }
2501    if let Some(e) = &t.ask_free {
2502        out.push_str(&format!("  grade 2 asked − handed over: {} over {} 2s (Horvitz–Thompson by the logged propensities, session 95% CI)\n", ci(e, 1.0), e.n));
2503    }
2504    out
2505}
2506
2507/// `borg debug select …` (module doc): the store of the collective DIR routes to (the working directory
2508/// by default), or one outcomes file alone. Exits 1 when a decisive check fails or tests nothing
2509/// (`unmet`), 2 on bad usage.
2510/// The hook's budget for selection after its model call, in ms at the 95th percentile. Guard: what
2511/// the shortest harness limit leaves beside the call (omp's 30 s stop with its 18 s call; codex's 10 s
2512/// tool hook), with room to spare; `--bench` exits 1 past it.
2513pub const BENCH_MS: f64 = 50.0;
2514
2515/// Milliseconds of one part of the hook's selection over the log's checks.
2516#[derive(Debug, Clone, Default, Serialize)]
2517pub struct Times {
2518    pub p50: f64,
2519    pub p95: f64,
2520    pub max: f64,
2521}
2522
2523impl Times {
2524    fn of(mut ms: Vec<f64>) -> Times {
2525        ms.sort_by(f64::total_cmp);
2526        let at = |q: f64| ms.get(((q * ms.len() as f64).ceil() as usize).saturating_sub(1)).copied().unwrap_or(0.0);
2527        Times { p50: at(0.5), p95: at(0.95), max: ms.last().copied().unwrap_or(0.0) }
2528    }
2529}
2530
2531/// `borg debug select --bench`: per graded check of the log that applies a lesson, the time the hook
2532/// spends selecting after its model call. `load` is select/summary.rs `refresh` of a cache that holds the
2533/// whole log, as a hook finds it; `decide` is select.rs `graded_under` under the learned policy with the
2534/// log's fit and its gate open (allowance and ledger set), so every path a check can take runs: the
2535/// evidence test over the joint draws, the question-count test's admission. The worst case, not the
2536/// store's state.
2537#[derive(Debug, Clone, Default, Serialize)]
2538pub struct Bench {
2539    pub checks: usize,
2540    pub load: Times,
2541    pub decide: Times,
2542    pub total: Times,
2543    /// Whether the fit had a model: without one the decision is the fixed rule's and costs nothing.
2544    pub model: bool,
2545}
2546
2547impl Bench {
2548    fn line(&self) -> String {
2549        let t = |x: &Times| format!("p50 {:.1} ms, p95 {:.1} ms, max {:.1} ms", x.p50, x.p95, x.max);
2550        let verdict = if self.total.p95 < BENCH_MS { "within" } else { "OVER" };
2551        let model = if self.model { "" } else { " (no model fitted: the fixed rule decides)" };
2552        format!(
2553            "selection cost per check, {} checks{model}\n  load    {}\n  decide  {}\n  total   {}: {verdict} the {BENCH_MS} ms budget\n",
2554            self.checks,
2555            t(&self.load),
2556            t(&self.decide),
2557            t(&self.total)
2558        )
2559    }
2560}
2561
2562/// `Bench` of `text` (the outcome log beside `lessons`): a copy of it with its refitted summary cached,
2563/// in a temp store, so nothing of the store is written.
2564fn bench(text: &str, lessons: &Path) -> Bench {
2565    let store = std::env::temp_dir().join(format!("borg-select-bench-{}", std::process::id()));
2566    let _ = std::fs::remove_dir_all(&store);
2567    if std::fs::create_dir_all(&store).is_err() {
2568        return Bench::default();
2569    }
2570    let at = store.join("lessons.jsonl");
2571    let mut s = summary::with_fit(summary::fold(text, Summary::default()), super::budget_of(lessons));
2572    // The gate open: what a check can risk is never the bound, and the ledger has its draws.
2573    s.gate.allowance = f64::MAX / 4.0;
2574    if s.gate.gain.len() != policy::DRAWS {
2575        s.gate.gain = vec![0.0; policy::DRAWS];
2576    }
2577    let saved = std::fs::write(outcome::path_beside(&at), text).is_ok() && summary::save(&at, &s).is_ok();
2578    let model = s.fit.model.is_some();
2579    let (mut load, mut decide, mut total) = (Vec::new(), Vec::new(), Vec::new());
2580    for o in lines(text).into_iter().filter_map(|l| l.o).filter(|o| o.kind == outcome::GRADED && !o.lessons.is_empty()) {
2581        let Some(cs) = challenges(&o) else { continue };
2582        if !saved {
2583            break;
2584        }
2585        let applied = applied(&cs, &o);
2586        let check = Check::new(o, &applied);
2587        let t0 = std::time::Instant::now();
2588        let s = summary::refresh(&at);
2589        let t1 = std::time::Instant::now();
2590        let r = super::graded_under(Policy::Learned, &applied, &ctx(&check.o, &check.had, &at, &s));
2591        let t2 = std::time::Instant::now();
2592        std::hint::black_box(r);
2593        let ms = |d: std::time::Duration| d.as_secs_f64() * 1000.0;
2594        load.push(ms(t1 - t0));
2595        decide.push(ms(t2 - t1));
2596        total.push(ms(t2 - t0));
2597    }
2598    let _ = std::fs::remove_dir_all(&store);
2599    Bench { checks: total.len(), load: Times::of(load), decide: Times::of(decide), total: Times::of(total), model }
2600}
2601
2602pub fn main(args: &[&str]) -> i32 {
2603    let usage = || {
2604        eprintln!(
2605            "usage: borg debug select [--replay | --bench] [--rebuild] [--dir DIR | --file OUTCOMES.jsonl] [--json] [--eval RUNS_DIR --qrels QRELS.jsonl]\n       borg debug select --replay --file OUTCOMES.jsonl --file OUTCOMES.jsonl ... [--json]  (the question-count test's readout pooled over the stores, stratified by collective: each file's directory names its collective)"
2606        );
2607        2
2608    };
2609    let (mut replay, mut rebuild, mut json, mut timed) = (false, false, false, false);
2610    let (mut dir, mut eval, mut qrels_path): (Option<&str>, Option<&str>, Option<&str>) = (None, None, None);
2611    let mut files: Vec<&str> = Vec::new();
2612    let mut it = args.iter();
2613    while let Some(a) = it.next() {
2614        let slot = match *a {
2615            "--file" if dir.is_none() => {
2616                match it.next() {
2617                    Some(v) => files.push(v),
2618                    None => return usage(),
2619                }
2620                continue;
2621            }
2622            "--replay" => {
2623                replay = true;
2624                continue;
2625            }
2626            "--rebuild" => {
2627                rebuild = true;
2628                continue;
2629            }
2630            "--json" => {
2631                json = true;
2632                continue;
2633            }
2634            "--bench" => {
2635                timed = true;
2636                continue;
2637            }
2638            "--dir" if files.is_empty() => &mut dir,
2639            "--eval" => &mut eval,
2640            "--qrels" => &mut qrels_path,
2641            _ => return usage(),
2642        };
2643        match (slot.is_none(), it.next()) {
2644            (true, Some(v)) => *slot = Some(v),
2645            _ => return usage(),
2646        }
2647    }
2648    if eval.is_some() != qrels_path.is_some() {
2649        return usage();
2650    }
2651    if files.len() > 1 {
2652        // Several stores: only the pooled readout (amendment 2026-10-31), never their checks at once.
2653        if !replay || timed || rebuild || eval.is_some() {
2654            return usage();
2655        }
2656        return pooled_main(&files, json);
2657    }
2658    let file = files.first().copied();
2659    let (source, lessons): (String, PathBuf) = match file {
2660        Some(f) => (format!("file {f}"), Path::new(f).with_file_name("lessons.jsonl")),
2661        None => {
2662            let cwd = dir.map_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string(), str::to_string);
2663            match collective::load().map(|cfg| collective::route(cfg.as_ref(), &cwd, &learn::home())) {
2664                Ok(collective::Route::Collective { name, home, .. }) => (format!("collective {name} ({})", home.display()), home.join("lessons.jsonl")),
2665                Ok(collective::Route::Off) => {
2666                    println!("off: {cwd} belongs to no collective; no outcomes are kept here");
2667                    return 0;
2668                }
2669                Err(e) => {
2670                    eprintln!("borg debug select: invalid collectives config ({e}); everything is off");
2671                    return 1;
2672                }
2673            }
2674        }
2675    };
2676    let outcomes = file.map_or_else(|| outcome::path_beside(&lessons), PathBuf::from);
2677    let text = std::fs::read_to_string(&outcomes).unwrap_or_default();
2678    if timed {
2679        let b = bench(&text, &lessons);
2680        match json {
2681            true => println!("{}", serde_json::json!({ "source": source, "bench": b })),
2682            false => print!("{source}\n{}", b.line()),
2683        }
2684        return i32::from(b.total.p95 >= BENCH_MS);
2685    }
2686    // A file alone has no store whose cache a hook would load: its summary is the file folded.
2687    let summary = match (file, rebuild) {
2688        (Some(_), _) => summary::fold(&text, Summary::default()),
2689        (None, true) => summary::rebuild(&lessons),
2690        (None, false) => summary::load(&lessons),
2691    };
2692    let report = evaluate(Input {
2693        text: &text,
2694        lessons: &lessons,
2695        summary,
2696        replay: replay || eval.is_some(),
2697        eval: eval.zip(qrels_path).map(|(e, q)| (Path::new(e), Path::new(q))),
2698    });
2699    match json {
2700        true => println!("{}", serde_json::json!({ "source": source, "report": report })),
2701        false => print!("{source}\n{}", render(&report)),
2702    }
2703    i32::from(unmet(&report))
2704}
2705
2706/// `borg debug select --replay --file A --file B …`: the question-count test's readout pooled over the
2707/// stores (`pooled`), each named after its file's directory (the store's, so its collective's), or
2708/// after the file where two directories share a name. Non-decisive: exits 0, 1 when a file is unreadable.
2709fn pooled_main(files: &[&str], json: bool) -> i32 {
2710    let name = |f: &str| Path::new(f).parent().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| f.to_string());
2711    let mut stores: Vec<(String, String)> = Vec::new();
2712    for f in files {
2713        match std::fs::read_to_string(f) {
2714            Ok(text) => {
2715                let shared = files.iter().filter(|g| name(g) == name(f)).count() > 1;
2716                stores.push((if shared { (*f).to_string() } else { name(f) }, text));
2717            }
2718            Err(e) => {
2719                eprintln!("borg debug select: cannot read {f}: {e}");
2720                return 1;
2721            }
2722        }
2723    }
2724    let p = pooled(&stores);
2725    let source = format!("files {}", files.join(", "));
2726    match json {
2727        true => println!("{}", serde_json::json!({ "source": source, "pooled": p })),
2728        false => print!("{source}\n{}", render_pooled(&p)),
2729    }
2730    0
2731}
2732
2733#[cfg(test)]
2734mod tests {
2735    use super::*;
2736
2737    /// Judge v3 (0.7.5): a point that never came up is a verdict of no change (the readout's 0 over its
2738    /// chance of a verdict, and a verdict for the judge-missingness fit), a remaining consequence an
2739    /// unclear one (0, no verdict); the older labels read as before.
2740    #[test]
2741    fn not_arisen_reads_as_no_change_and_a_remaining_consequence_as_unclear() {
2742        use summary::{Apply, Check, Delivered, Label, Summary};
2743        let labels = [Label::Changed, Label::Ignored, Label::NotArisen, Label::Remains, Label::Unclear, Label::Censored, Label::Open];
2744        let summary = |of: &[Label]| Summary {
2745            applies: of.iter().map(|&label| Apply { label, route: Route::Free, delivered: Delivered::Yes, grade: 2, ..Default::default() }).collect(),
2746            checks: vec![Check { stop: true, first: 0, n: of.len() as u16, ..Default::default() }],
2747            ..Default::default()
2748        };
2749        let s = summary(&labels);
2750        let got: Vec<Option<f64>> = (0..labels.len()).map(|i| unit_outcome(&s, &HashMap::new(), i)).collect();
2751        assert_eq!(got, [Some(1.0), Some(0.0), Some(0.0), Some(0.0), Some(0.0), Some(0.0), None]);
2752        // The judge-missingness fit: not arisen is judged, a remaining consequence is not.
2753        let p = |label: Label| policy::judged(&summary(&[label; 40])).get(&0).copied().unwrap();
2754        assert!(p(Label::NotArisen) > 0.9 && p(Label::Remains) < 0.1, "{} {}", p(Label::NotArisen), p(Label::Remains));
2755        assert_eq!(p(Label::NotArisen), p(Label::Changed));
2756        assert_eq!(p(Label::Remains), p(Label::Unclear));
2757    }
2758
2759    fn graded(session: &str, ms: i64, lessons: &[&str], grades: &[u8]) -> Outcome {
2760        let cs: Vec<Challenge> = lessons.iter().map(|id| Challenge { id: id.to_string(), ..Default::default() }).collect();
2761        let applied: Vec<(&Challenge, u8)> = cs.iter().zip(grades.iter().copied()).collect();
2762        let (ask, _) = conscience::graded_capped(&applied, None);
2763        Outcome {
2764            kind: outcome::GRADED.into(),
2765            session: session.into(),
2766            lessons: lessons.iter().map(|l| l.to_string()).collect(),
2767            ms,
2768            repo: "r".into(),
2769            channel: "live".into(),
2770            grades: grades.to_vec(),
2771            ranks: (0..lessons.len() as u16).collect(),
2772            applied: lessons.len() as u16,
2773            chosen: ids(&ask),
2774            p: holds(&lessons.iter().map(|l| l.to_string()).collect::<Vec<_>>(), &ids(&ask)),
2775            ..Default::default()
2776        }
2777    }
2778
2779    fn result(kind: &str, session: &str, lesson: &str, ms: i64, channel: &str) -> Outcome {
2780        Outcome {
2781            kind: kind.into(),
2782            session: session.into(),
2783            lessons: vec![lesson.into()],
2784            ms,
2785            repo: "r".into(),
2786            channel: channel.into(),
2787            ..Default::default()
2788        }
2789    }
2790
2791    fn text(log: &[Outcome]) -> String {
2792        log.iter().map(|o| format!("{}\n", serde_json::to_string(o).unwrap())).collect()
2793    }
2794
2795    fn checks_of(log: &[Outcome]) -> Vec<Check> {
2796        as_logged(&text(log), Path::new("/nonexistent/lessons.jsonl")).checks
2797    }
2798
2799    #[test]
2800    fn the_bench_reports_nearest_rank_percentiles() {
2801        let t = Times::of((1..=20).rev().map(f64::from).collect());
2802        assert_eq!((t.p50, t.p95, t.max), (10.0, 19.0, 20.0));
2803        assert_eq!((Times::of(Vec::new()).p95, Times::of(vec![4.0]).p95), (0.0, 4.0));
2804    }
2805
2806    #[test]
2807    fn a_fixed_log_reproduces_and_a_changed_decision_fails_p0_and_p1() {
2808        let log = [graded("s", 1, &["A", "B"], &[3, 1]), graded("s", 2, &["C", "D"], &[2, 1]), result(outcome::CHANGED, "s", "A", 3, "live")];
2809        let pass = as_logged(&text(&log), Path::new("/nonexistent/lessons.jsonl"));
2810        assert_eq!((pass.p0.verdict, pass.p0.checks, pass.p0.differ), (Verdict::Pass, 2, 0));
2811        assert_eq!((pass.p1.verdict, pass.p1.replayed, pass.p1.reproduced, pass.p1.max_delta), (Verdict::Pass, 2, 2, 0.0));
2812        assert!(pass.p1.fold);
2813        // The second check logged only one of its free lessons: the fixed rule hands over both.
2814        let mut other = log.clone();
2815        other[1].policy = "fixed".into();
2816        other[1].free = vec!["C".into()];
2817        other[1].pf = vec![1.0, 0.0];
2818        let pass = as_logged(&text(&other), Path::new("/nonexistent/lessons.jsonl"));
2819        assert_eq!((pass.p0.verdict, pass.p0.differ), (Verdict::Fail, 1), "{:?}", pass.p0.first);
2820        assert_eq!((pass.p1.verdict, pass.p1.reproduced), (Verdict::Fail, 1));
2821        assert!(pass.p1.max_delta >= 1.0 && pass.p1.first[0].contains("check - ms 2"), "{:?}", pass.p1);
2822        // Under an evaluation switch: counted, not replayed.
2823        let mut cut = log.clone();
2824        cut[0].policy = "cap".into();
2825        let pass = as_logged(&text(&cut), Path::new("/nonexistent/lessons.jsonl"));
2826        assert_eq!((pass.p1.replayed, pass.p1.switches), (1, 1));
2827    }
2828
2829    /// P0 and P1 are NOT TESTED, never a pass, when no check they replay applies a lesson: every policy
2830    /// decides nothing on a check none applied to or a failed call, so the comparison cannot fail on
2831    /// the policy. One check with an apply tests both; P1 alone is NOT TESTED when every check ran under
2832    /// an evaluation switch.
2833    #[test]
2834    fn p0_and_p1_on_checks_that_decide_nothing_are_not_tested() {
2835        let none = Path::new("/nonexistent/lessons.jsonl");
2836        let pass = as_logged(&text(&[result(outcome::CHANGED, "s", "A", 1, "live")]), none);
2837        assert_eq!((pass.p0.verdict, pass.p0.checks, pass.p1.verdict, pass.p1.replayed), (Verdict::NotTested, 0, Verdict::NotTested, 0));
2838        // A check none applied to, and a failed learned call at the stop.
2839        let failed = Outcome {
2840            failed: true,
2841            policy: "learned".into(),
2842            check: crate::select::check_id("s", "stop", 3, 1),
2843            channel: "stop".into(),
2844            ..graded("s", 3, &[], &[])
2845        };
2846        let mut log = vec![graded("s", 2, &[], &[]), failed, result(outcome::CHANGED, "s", "A", 4, "live")];
2847        let pass = as_logged(&text(&log), none);
2848        assert_eq!((pass.p0.verdict, pass.p0.checks, pass.p0.empty, pass.p0.differ), (Verdict::NotTested, 2, 2, 0), "{:?}", pass.p0);
2849        assert_eq!((pass.p1.verdict, pass.p1.replayed, pass.p1.reproduced, pass.p1.empty), (Verdict::NotTested, 2, 2, 2), "{:?}", pass.p1);
2850        // The command fails on P0 or P1 NOT TESTED alone, the other checks passing.
2851        let mut report = evaluate(Input { text: &text(&log), lessons: none, summary: Summary::default(), replay: true, eval: None });
2852        let c = report.checks.as_mut().unwrap();
2853        assert_eq!((c.p0.verdict, c.p1.verdict), (Verdict::NotTested, Verdict::NotTested));
2854        (c.p1.verdict, c.p2.verdict, c.p3.verdict, c.p4.verdict) = (Verdict::Pass, Verdict::Pass, Verdict::Pass, Verdict::NotRun);
2855        assert!(unmet(&report));
2856        // One check with an apply: both tested, the others still counted and named.
2857        log.push(graded("s", 5, &["A", "B"], &[3, 1]));
2858        let pass = as_logged(&text(&log), none);
2859        assert_eq!((pass.p0.verdict, pass.p0.checks, pass.p0.empty), (Verdict::Pass, 3, 2), "{:?}", pass.p0);
2860        assert_eq!((pass.p1.verdict, pass.p1.replayed, pass.p1.empty), (Verdict::Pass, 3, 2), "{:?}", pass.p1);
2861        let shown = render(&evaluate(Input { text: &text(&log), lessons: none, summary: Summary::default(), replay: true, eval: None }));
2862        assert!(shown.contains("(bar: every check); 2 of them apply no lesson and test nothing\n"), "{shown}");
2863        assert!(shown.contains("their record names; 2 of them apply no lesson and test only the summary and source they name;"), "{shown}");
2864        // Every check under an evaluation switch: P0 decides them, P1 replays none.
2865        let cut = [Outcome { policy: "cap".into(), ..graded("s", 6, &["A"], &[2]) }];
2866        let pass = as_logged(&text(&cut), none);
2867        assert_eq!((pass.p0.verdict, pass.p0.checks), (Verdict::Pass, 1), "{:?}", pass.p0);
2868        assert_eq!((pass.p1.verdict, pass.p1.replayed, pass.p1.switches), (Verdict::NotTested, 0, 1), "{:?}", pass.p1);
2869    }
2870
2871    /// Records copied in from another store's log name summaries and fits at its bytes: where such a
2872    /// byte is no line boundary of this log, P1 counts the record as imported and skips it, instead of
2873    /// failing on a summary this log never had (an open-source store on 2026-10-31: 50 such checks).
2874    /// A byte at a boundary is this log's, so a wrong key there still fails.
2875    #[test]
2876    fn p1_skips_records_imported_from_another_log() {
2877        let none = Path::new("/nonexistent/lessons.jsonl");
2878        let log = labelled(3, 6, 5);
2879        let graded_at: Vec<usize> = log.iter().enumerate().filter(|(_, o)| o.kind == outcome::GRADED).map(|(i, _)| i).collect();
2880        let clean = as_logged(&text(&log), none).p1;
2881        assert_eq!((clean.verdict, clean.imported), (Verdict::Pass, 0), "{clean:?}");
2882        let mut copied = log.clone();
2883        // Byte 5 lies inside the first line; a fit at a byte past this log's end.
2884        copied[graded_at[1]].seen = "5-abcdef01".into();
2885        copied[graded_at[2]].fit = format!("{}-abcdef02", 1u64 << 40);
2886        let p1 = as_logged(&text(&copied), none).p1;
2887        assert_eq!((p1.verdict, p1.imported, p1.replayed + 2), (Verdict::Pass, 2, clean.replayed), "{p1:?}");
2888        // At a boundary of this log (its start), a fit no refit gave there is a failure, not an import.
2889        copied[graded_at[1]].seen.clear();
2890        copied[graded_at[1]].fit = "0-abcdef01".into();
2891        let p1 = as_logged(&text(&copied), none).p1;
2892        assert_eq!((p1.verdict, p1.imported), (Verdict::Fail, 1), "{p1:?}");
2893    }
2894
2895    /// P1 replays a learned decision from the summary its record names (`seen`): the hook read it
2896    /// before its model call, so a record another check appended meanwhile lies before this one but not
2897    /// in it, and that record's entry on the gate's ledger must not count. The revert it logged
2898    /// (`source`) counts too: the sticky mark beside the store is in no log line.
2899    #[test]
2900    fn p1_replays_each_decision_from_the_summary_its_record_names() {
2901        let none = Path::new("/nonexistent/lessons.jsonl");
2902        let mut log = labelled(6, 12, 11);
2903        let fitted = summary::with_fit(summary::fold(&text(&log), Summary::default()), None);
2904        assert!(fitted.fit.model.is_some() && fitted.gate.allowance == 0.0, "a model, and no learned check on its ledger: {:?}", fitted.gate);
2905        // A hook's decision on the summary `s` it read under the collective's tolerance `alpha` (hook.rs
2906        // `decide`), logged as hook.rs `record_graded` logs it.
2907        let decide_at = |o: Outcome, s: &Summary, alpha: f64| {
2908            let cs = challenges(&o).unwrap();
2909            let r = crate::select::graded_under(Policy::Learned, &applied(&cs, &o), &Ctx { alpha, ..ctx(&o, &had_of(&o), none, s) });
2910            let round = |v: &[f64]| v.iter().map(|p| outcome::round_p(*p)).collect();
2911            Outcome {
2912                chosen: ids(&r.ask),
2913                free: ids(&r.free),
2914                placebo: ids(&r.placebo),
2915                p: round(&r.p),
2916                pf: round(&r.pf),
2917                psi: r.log.psi,
2918                policy: r.log.policy.into(),
2919                fit: r.log.fit.clone(),
2920                seen: s.key(),
2921                source: r.log.source.into(),
2922                xk: r.log.xk,
2923                xs: r.log.xs,
2924                alpha: r.log.alpha,
2925                ..o
2926            }
2927        };
2928        let decide = |o: Outcome, s: &Summary| decide_at(o, s, policy::ALPHA);
2929        let check =
2930            |ms: i64, lessons: &[&str], grades: &[u8]| Outcome { check: crate::select::check_id("t", "live", ms, 1), ..graded("t", ms, lessons, grades) };
2931        // x and b read the refit; a reads it after x; then x's, a's and b's records land in that order.
2932        let x = decide(check(100_001, &["L1", "L2", "L3"], &[2, 1, 2]), &fitted);
2933        let after_x = summary::fold(&text(std::slice::from_ref(&x)), fitted.clone());
2934        assert!(after_x.gate.allowance > 0.0, "x's check gives the gate a budget b never read: {:?}", after_x.gate);
2935        let a = decide(check(100_002, &["L4", "L5"], &[1, 2]), &after_x);
2936        let b = decide(check(100_003, &["L6", "L7", "L8"], &[1, 2, 1]), &fitted);
2937        assert_eq!((a.seen != b.seen, b.source.as_str()), (true, "fixed"));
2938        // c reads all three, the store reverted by the mark beside it.
2939        let mut reverted = summary::fold(&text(&[x.clone(), a.clone(), b.clone()]), fitted.clone());
2940        reverted.monitor.reverted = true;
2941        let c = decide(check(100_004, &["L9", "L10"], &[2, 2]), &reverted);
2942        assert_eq!(c.source, crate::select::REVERTED_SOURCE);
2943        log.extend([x, a, b, c]);
2944        let pass = as_logged(&text(&log), none);
2945        assert_eq!((pass.p1.verdict, pass.p1.reproduced, pass.p1.seen), (Verdict::Pass, pass.p1.replayed, 4), "{:?}", pass.p1.first);
2946        // a's hook under a collective's `risk` 0, which the replay's path maps to no collective: the
2947        // record's α decides, not the config's then (c goes: a's other line would change c's summary).
2948        let a_at = log.len() - 3;
2949        assert!(log[a_at].xk.is_some(), "at the default α a is tested: {:?}", log[a_at]);
2950        let mut off = log[..log.len() - 1].to_vec();
2951        off[a_at] = decide_at(check(100_002, &["L4", "L5"], &[1, 2]), &after_x, 0.0);
2952        assert_eq!((off[a_at].alpha, off[a_at].xk), (Some(0.0), None), "{:?}", off[a_at]);
2953        let pass = as_logged(&text(&off), none);
2954        assert_eq!((pass.p1.verdict, pass.p1.reproduced), (Verdict::Pass, pass.p1.replayed), "{:?}", pass.p1.first);
2955        // Without the α on its record, it replays at the default and is not the decision its hook made.
2956        off[a_at].alpha = None;
2957        let pass = as_logged(&text(&off), none);
2958        assert_eq!((pass.p1.verdict, pass.p1.reproduced + 1), (Verdict::Fail, pass.p1.replayed), "{:?}", pass.p1.first);
2959        // The question-count test's draw (k, s) is the check's seed's too: a record logging another s, its
2960        // routes and propensities unchanged, is not the decision its hook made (c goes: a's other line
2961        // would change the summary c's record names).
2962        let mut drawn = log[..log.len() - 1].to_vec();
2963        let a_at = drawn.len() - 2;
2964        drawn[a_at].xs = Some(drawn[a_at].xs.map_or(1, |s| s + 1));
2965        let pass = as_logged(&text(&drawn), none);
2966        assert_eq!((pass.p1.verdict, pass.p1.reproduced + 1), (Verdict::Fail, pass.p1.replayed), "{:?}", pass.p1.first);
2967        assert!(pass.p1.first[0].contains("ms 100002") && pass.p1.first[0].contains(" s "), "{:?}", pass.p1.first);
2968        // Replayed on every line before it instead, b's decision is not the one its hook made (c goes:
2969        // b's shorter line would move the byte c's summary names).
2970        log.pop();
2971        let b_at = log.len() - 1;
2972        log[b_at].seen.clear();
2973        let pass = as_logged(&text(&log), none);
2974        assert_eq!((pass.p1.verdict, pass.p1.reproduced + 1), (Verdict::Fail, pass.p1.replayed), "{:?}", pass.p1.first);
2975        assert!(pass.p1.first[0].contains("ms 100003") && pass.p1.first[0].contains("by fixed"), "{:?}", pass.p1.first);
2976        // A summary the log does not hold at the byte it names is not replayed on another.
2977        log[b_at].seen = format!("{}-00000000", log[b_at].fit.split('-').next().unwrap());
2978        let pass = as_logged(&text(&log), none);
2979        assert!(pass.p1.first.iter().any(|m| m.contains("ms 100003") && m.contains("no summary")), "{:?}", pass.p1.first);
2980    }
2981
2982    #[test]
2983    fn verdicts_join_the_check_that_routed_their_delivery() {
2984        let mut log = vec![
2985            graded("s", 10, &["A", "B"], &[3, 1]), // asks A and B
2986            graded("s", 20, &["A", "C"], &[2, 1]), // hands A and C over free
2987            result(outcome::CHANGED, "s", "A", 25, "live"),
2988            result(outcome::CHANGED, "s", "A", 26, "queued"),
2989            result(outcome::IGNORED, "s", "C", 27, "queued"),
2990            result(outcome::CHANGED, "s", "B", 28, "early"),
2991            result(outcome::NOT_CORRECTED, "s", "B", 29, ""),
2992            result(outcome::CHANGED, "t", "Z", 30, "live"),
2993        ];
2994        let checks = checks_of(&log);
2995        let (ls, unjoined) = labels(&checks, &log);
2996        let got: Vec<(usize, usize, usize, f64)> = ls.iter().map(|l| (l.check, l.j, l.route, l.y)).collect();
2997        assert_eq!(got, [(0, 0, ASK, 1.0), (1, 0, FREE, 1.0), (1, 1, FREE, 0.0)], "early and censored results label nothing");
2998        assert_eq!(unjoined, 1, "Z was graded by no check");
2999        assert_eq!(ls[1].mu, [0.0, 1.0, 0.0]);
3000        // A veto after the check outranks the change; one before it does not.
3001        log.push(result(outcome::VETOED, "s", "A", 21, ""));
3002        let (ls, _) = labels(&checks, &log);
3003        assert_eq!(ls.iter().map(|l| l.y).collect::<Vec<_>>(), [0.0, 0.0, 0.0]);
3004        log.pop();
3005        log.push(result(outcome::VETOED, "s", "A", 5, ""));
3006        assert_eq!(labels(&checks, &log).0[0].y, 1.0);
3007        // A check id joins whatever the times.
3008        let mut a = graded("s", 100, &["Q"], &[3]);
3009        a.check = "abcdef012345".into();
3010        let late = Outcome { check: "abcdef012345".into(), ..result(outcome::CHANGED, "s", "Q", 50, "stop") };
3011        let log = [a, late];
3012        assert_eq!(labels(&checks_of(&log), &log).0.len(), 1);
3013        // The fixed rule's placebo: a leftover judged blindly joins its check as not delivered, at the
3014        // fixed rule's route probabilities; a verdict on the route it did not take joins nothing.
3015        let lessons: Vec<String> = (0..11).map(|i| format!("L{i}")).collect();
3016        let mut f = graded("s", 200, &lessons.iter().map(String::as_str).collect::<Vec<_>>(), &[1; 11]);
3017        (f.check, f.policy, f.placebo, f.psi) = ("0123456789ab".into(), "fixed".into(), vec!["L10".into()], 0.5);
3018        f.free = lessons[..10].to_vec();
3019        f.pf = holds(&f.lessons, &f.free);
3020        let blind = Outcome { check: "0123456789ab".into(), ..result(outcome::CHANGED, "s", "L10", 210, outcome::PLACEBO) };
3021        let wrong = Outcome { check: "0123456789ab".into(), ..result(outcome::CHANGED, "s", "L10", 211, "queued") };
3022        let log = [f, blind, wrong];
3023        let (ls, unjoined) = labels(&checks_of(&log), &log);
3024        assert_eq!((ls.len(), unjoined), (1, 1));
3025        // Its chance of a label is the draw's: routed nowhere (1) and drawn as a placebo (ψ).
3026        assert_eq!((ls[0].j, ls[0].route, ls[0].mu, ls[0].y), (10, NOT, [0.0, 0.0, 0.5], 1.0));
3027    }
3028
3029    /// A point that never came up is a verdict of no change (0) for the replay's labels, as for the
3030    /// selection model's fit (policy.rs `Data::of`): asked, handed over free or drawn as a placebo; a
3031    /// later change in the session still outranks it.
3032    #[test]
3033    fn a_not_arisen_verdict_labels_its_unit_no_change_on_every_route() {
3034        let mut log = vec![
3035            graded("s", 10, &["A", "B"], &[3, 3]), // asks A and B
3036            graded("s", 20, &["C"], &[2]),         // hands C over free
3037            result(outcome::NOT_ARISEN, "s", "A", 25, "stop"),
3038            result(outcome::NOT_ARISEN, "s", "C", 26, "queued"),
3039            result(outcome::NOT_ARISEN, "s", "B", 27, "live"),
3040            result(outcome::CHANGED, "s", "B", 40, "stop"),
3041            result(outcome::REMAINS, "s", "A", 41, "stop"),
3042            result(outcome::UNCLEAR, "s", "C", 42, "queued"),
3043        ];
3044        let (ls, unjoined) = labels(&checks_of(&log), &log);
3045        let got: Vec<(usize, usize, usize, f64)> = ls.iter().map(|l| (l.check, l.j, l.route, l.y)).collect();
3046        assert_eq!(got, [(0, 0, ASK, 0.0), (0, 1, ASK, 1.0), (1, 0, FREE, 0.0)], "unclear and remaining label nothing");
3047        assert_eq!(unjoined, 0);
3048        assert_eq!(ls[1].known, [(27, 0.0), (40, 1.0)]);
3049        // A placebo's not arisen labels its not-delivered unit 0.
3050        let lessons: Vec<String> = (0..11).map(|i| format!("L{i}")).collect();
3051        let mut f = graded("s", 200, &lessons.iter().map(String::as_str).collect::<Vec<_>>(), &[1; 11]);
3052        (f.check, f.policy, f.placebo, f.psi) = ("0123456789ab".into(), "fixed".into(), vec!["L10".into()], 0.5);
3053        f.free = lessons[..10].to_vec();
3054        f.pf = holds(&f.lessons, &f.free);
3055        let blind = Outcome { check: "0123456789ab".into(), ..result(outcome::NOT_ARISEN, "s", "L10", 210, outcome::PLACEBO) };
3056        log = vec![f, blind];
3057        let (ls, _) = labels(&checks_of(&log), &log);
3058        assert_eq!(ls.iter().map(|l| (l.route, l.y)).collect::<Vec<_>>(), [(NOT, 0.0)]);
3059    }
3060
3061    /// Each unit is predicted from the results before its check: a result arriving later moves no
3062    /// earlier prediction, one before it does.
3063    #[test]
3064    fn predictions_are_leak_free() {
3065        let unit = |check_ms: i64, result_ms: i64, y: f64| Label {
3066            check: 0,
3067            j: 0,
3068            grade: 1,
3069            route: ASK,
3070            mu: [1.0, 0.0, 0.0],
3071            y,
3072            session: "s".into(),
3073            check_ms,
3074            known: vec![(result_ms, y)],
3075            judge: 2,
3076        };
3077        let early = vec![unit(10, 50, 1.0), unit(20, 60, 1.0)];
3078        let p = |ls: &[Label]| past_counts(ls).iter().zip(ls).map(|(c, l)| by_grade(c, l.grade)).collect::<Vec<_>>();
3079        assert_eq!(p(&early), [0.5, 0.5], "no result before either check");
3080        let mut later = early.clone();
3081        later.push(unit(100, 110, 1.0));
3082        assert_eq!(&p(&later)[..2], &[0.5, 0.5], "the future moves nothing");
3083        assert_eq!(p(&later)[2], beta(2, 2), "both results came before it");
3084        let mut before = early.clone();
3085        before.push(unit(1, 5, 1.0));
3086        assert_eq!(&p(&before)[..2], &[beta(1, 1), beta(1, 1)], "a result before both checks moves both");
3087    }
3088
3089    fn world(n: usize, free_better: bool) -> Vec<Label> {
3090        // Grade-1 units, randomised: asked or free with probability 1/2, in 2-unit checks per session.
3091        (0..n)
3092            .map(|i| {
3093                let route = if i % 2 == 0 { ASK } else { FREE };
3094                let changed = if route == FREE { i % 10 != 1 } else { i % 10 == 0 };
3095                let y = f64::from(u8::from(changed == free_better));
3096                Label {
3097                    check: i / 2,
3098                    j: i % 2,
3099                    grade: 1,
3100                    route,
3101                    mu: [0.5, 0.5, 0.0],
3102                    y,
3103                    session: format!("s{}", i / 4),
3104                    check_ms: 10 * (i as i64 / 2),
3105                    known: vec![(10 * (i as i64 / 2) + 5, y)],
3106                    judge: 2,
3107                }
3108            })
3109            .collect()
3110    }
3111
3112    /// A predictor that ignores what it was fitted on.
3113    fn fixed_p<'a>(f: impl Fn(&Label) -> f64 + 'a) -> Option<Predictor<'a>> {
3114        Some(Box::new(f))
3115    }
3116
3117    /// Knowing the route, in the world where free changes and asked does not.
3118    fn by_route(l: &Label) -> f64 {
3119        if l.route == FREE { 0.85 } else { 0.15 }
3120    }
3121
3122    /// The route backwards.
3123    fn backwards(l: &Label) -> f64 {
3124        1.0 - by_route(l)
3125    }
3126
3127    #[test]
3128    fn calibration_passes_a_model_no_worse_than_grade_only_and_fails_a_worse_one() {
3129        let ls = world(80, true);
3130        let none = calibration(&ls, 0, &|_, _, _| None);
3131        assert_eq!((none.verdict, none.labels, none.sessions), (Verdict::NotRun, 80, 20));
3132        assert!(none.why.contains("no value model") && none.grade_only > 0.0);
3133        // The grade-only model itself: difference 0. Every check of this world has one change and one
3134        // not, so grade only says 1/2 throughout, and so does its copy: not tested.
3135        let grade_only = |_, past: &[&Label], at| {
3136            let mut c: Counts = [[(0, 0); ROUTES]; GRADES];
3137            for (l, (_, y)) in past.iter().filter_map(|l| l.at(at).map(|k| (l, k))) {
3138                c[usize::from(l.grade)][l.route].0 += 1;
3139                c[usize::from(l.grade)][l.route].1 += u32::from(y > 0.5);
3140            }
3141            fixed_p(move |l: &Label| by_grade(&c, l.grade))
3142        };
3143        assert_eq!(calibration(&ls, 0, &grade_only).verdict, Verdict::NotTested);
3144        // Two changes in three: grade only learns, and its copy is no worse.
3145        let tilted: Vec<Label> = ls
3146            .iter()
3147            .enumerate()
3148            .map(|(i, l)| {
3149                let y = f64::from(u8::from(i % 3 != 0));
3150                Label { y, known: vec![(l.known[0].0, y)], ..l.clone() }
3151            })
3152            .collect();
3153        let same = calibration(&tilted, 0, &grade_only);
3154        assert_eq!(same.verdict, Verdict::Pass, "{same:?}");
3155        assert!(same.diff.as_ref().is_some_and(|d| d.value == Some(0.0)) && same.pruned.is_none(), "a pass is not rerun");
3156        let route = calibration(&ls, 0, &|_, _, _| fixed_p(by_route));
3157        assert_eq!(route.verdict, Verdict::Pass);
3158        assert!(route.diff.as_ref().is_some_and(|d| d.hi < 0.0), "{route:?}");
3159        // The route backwards is worse, and P2 says so; the pruned rerun misses again.
3160        let wrong = calibration(&ls, 0, &|_, _, _| fixed_p(backwards));
3161        assert_eq!(wrong.verdict, Verdict::Fail, "{wrong:?}");
3162        assert!(wrong.pruned.as_ref().is_some_and(|r| r.verdict == Verdict::Fail), "a second miss fails P2: {wrong:?}");
3163    }
3164
3165    /// P2's pre-registered rerun: after the value model's FAIL, the pruned model is fitted and compared
3166    /// once, and its verdict is P2's; the value model's numbers stay reported beside it.
3167    #[test]
3168    fn a_failed_calibration_reruns_once_with_the_pruned_model() {
3169        let ls = world(80, true);
3170        let asked = std::cell::RefCell::new(Vec::new());
3171        let p2 = calibration(&ls, 0, &|value, _, _| {
3172            asked.borrow_mut().push(value);
3173            if value == Value::Full { fixed_p(backwards) } else { fixed_p(by_route) }
3174        });
3175        assert_eq!((p2.verdict, p2.pruned.as_ref().map(|r| r.verdict)), (Verdict::Pass, Some(Verdict::Pass)), "{p2:?}");
3176        assert!(p2.diff.as_ref().is_some_and(|d| d.hi > CALIBRATION), "the full model's miss is kept: {p2:?}");
3177        assert!(p2.pruned.as_ref().is_some_and(|r| r.diff.hi < 0.0 && r.model < p2.grade_only), "{p2:?}");
3178        let asked = asked.into_inner();
3179        let first_pruned = asked.iter().position(|v| *v == Value::Pruned);
3180        assert!(
3181            first_pruned.is_some_and(|i| asked[..i].iter().all(|v| *v == Value::Full) && asked[i..].iter().all(|v| *v == Value::Pruned)),
3182            "the full model, then the rerun once: {asked:?}"
3183        );
3184        let text = render(&Report {
3185            rules: RULES,
3186            amendment: AMENDMENT,
3187            rules_v2: RULES_V2,
3188            policy: "fixed",
3189            summary: Summary::default(),
3190            records: 0,
3191            graded: 0,
3192            malformed: 0,
3193            policies: BTreeMap::new(),
3194            checks: Some(Checks {
3195                p0: P0 { verdict: Verdict::Pass, checks: 1, empty: 0, differ: 0, first: Vec::new() },
3196                p1: P1 {
3197                    verdict: Verdict::Pass,
3198                    replayed: 1,
3199                    reproduced: 1,
3200                    empty: 0,
3201                    seen: 0,
3202                    tests: 0,
3203                    switches: 0,
3204                    imported: 0,
3205                    max_delta: 0.0,
3206                    fold: true,
3207                    first: Vec::new(),
3208                },
3209                p2,
3210                p3: conduct(&[], Spend::default(), Spend::default(), None, 0.0),
3211                p4: P4::not_run("x"),
3212                p5: off_policy(&[], &[], &[]),
3213                readout: readout(&[], &[], &Summary::default()),
3214            }),
3215        });
3216        assert!(text.contains("P2 calibration: PASS by the pruned model: leave-future-out log-loss model "), "{text}");
3217        assert!(
3218            text.contains("  pre-registered rerun, the model pruned to grade, route and dilution: log-loss ")
3219                && text.contains(": PASS: P2 passes on the pruned model"),
3220            "{text}"
3221        );
3222    }
3223
3224    /// P5 estimates nothing without a judged unit: NOT TESTED, not an estimate of 0 units. One judged
3225    /// unit makes it an estimate (REPORTED).
3226    #[test]
3227    fn off_policy_without_a_judged_unit_is_not_tested() {
3228        let p5 = off_policy(&[], &[], &[]);
3229        assert_eq!((p5.verdict, p5.units), (Verdict::NotTested, 0), "{p5:?}");
3230        let log = [graded("s", 10, &["A", "B"], &[3, 1]), result(outcome::CHANGED, "s", "A", 25, "live")];
3231        let checks = checks_of(&log);
3232        let (ls, _) = labels(&checks, &log);
3233        let p5 = off_policy(&checks, &ls, &[]);
3234        assert_eq!((p5.verdict, p5.units), (Verdict::Reported, 1), "{p5:?}");
3235    }
3236
3237    /// P2 is NOT TESTED, never a pass, when it tested nothing: no label, no unit with a label before
3238    /// its check, or every prediction of both models 1/2.
3239    #[test]
3240    fn calibration_on_nothing_to_learn_from_is_not_tested() {
3241        let empty = calibration(&[], 0, &|_, _, _| fixed_p(by_route));
3242        assert_eq!((empty.verdict, empty.why.as_str()), (Verdict::NotTested, "no judged unit joins a graded check"));
3243        // Every result after every check.
3244        let late: Vec<Label> = world(20, true).into_iter().map(|l| Label { known: vec![(10_000, l.y)], ..l }).collect();
3245        let blind = calibration(&late, 0, &|_, _, _| fixed_p(by_route));
3246        assert_eq!((blind.verdict, blind.informed), (Verdict::NotTested, 0), "{blind:?}");
3247        // One change and one not per check: grade only says 1/2 wherever it knows anything, and so does
3248        // the model.
3249        let balanced: Vec<Label> = world(80, true)
3250            .into_iter()
3251            .enumerate()
3252            .map(|(i, l)| {
3253                let y = f64::from(u8::from(i % 2 == 0));
3254                Label { y, known: vec![(l.known[0].0, y)], ..l }
3255            })
3256            .collect();
3257        let coin = calibration(&balanced, 0, &|_, _, _| fixed_p(|_| 0.5));
3258        assert!(coin.informed > 0 && coin.model.is_some(), "{coin:?}");
3259        assert_eq!((coin.verdict, coin.why.as_str()), (Verdict::NotTested, "every prediction of both models is 1/2"), "{coin:?}");
3260        // The same units under an informative model are tested.
3261        assert_ne!(calibration(&balanced, 0, &|_, _, _| fixed_p(by_route)).verdict, Verdict::NotTested);
3262    }
3263
3264    /// The guard: a model certain of the wrong answer costs a large but finite log-loss, so the mean
3265    /// and its interval stay numbers. Without `CLAMP` this unit's loss is infinite.
3266    #[test]
3267    fn a_certain_wrong_prediction_keeps_the_log_loss_finite() {
3268        assert!(log_loss(1.0, 0.0).is_finite() && log_loss(0.0, 1.0).is_finite());
3269        let certain = calibration(&world(40, true), 0, &|_, _, _| fixed_p(|l: &Label| if l.route == FREE { 1.0 } else { 0.0 }));
3270        assert!(certain.model.is_some_and(f64::is_finite), "{certain:?}");
3271        assert!(certain.diff.as_ref().is_some_and(|d| d.hi.is_finite()), "{certain:?}");
3272        assert_eq!(certain.verdict, Verdict::Fail, "one certain miss outweighs every right call");
3273    }
3274
3275    /// Where free beats asked for grade 1 (randomised logging), DR ranks a policy that hands grade 1
3276    /// over free above the fixed rule, which asks it, and the readout's within-check Q−F says the same.
3277    #[test]
3278    fn a_planted_world_where_free_beats_asked_ranks_free_above_asked() {
3279        let ls = world(120, true);
3280        let past = past_counts(&ls);
3281        let ss = sessions(&ls);
3282        let asks = |_: &Label| [1.0, 0.0, 0.0];
3283        let frees = |_: &Label| [0.0, 1.0, 0.0];
3284        let row = doubly_robust(&ls, &past, &ss, &frees, &asks, "free");
3285        let d = row.dr.value.unwrap();
3286        assert!(d > 0.5 && row.dr.lo > 0.0, "{row:?}");
3287        assert_eq!(row.coverage, 1.0, "randomised: the log covers every route the policy takes");
3288        let back = doubly_robust(&ls, &past, &ss, &asks, &frees, "asked");
3289        assert!(back.dr.value.unwrap() < -0.5 && back.dr.hi < 0.0, "{back:?}");
3290        // The mirror world reverses the ranking.
3291        let mirror = world(120, false);
3292        let row = doubly_robust(&mirror, &past_counts(&mirror), &sessions(&mirror), &frees, &asks, "free");
3293        assert!(row.dr.hi < 0.0, "{row:?}");
3294        // Deterministic logging (every unit asked): the free policy's route is not covered.
3295        let fixed: Vec<Label> = ls.iter().map(|l| Label { route: ASK, mu: [1.0, 0.0, 0.0], ..l.clone() }).collect();
3296        assert_eq!(doubly_robust(&fixed, &past_counts(&fixed), &sessions(&fixed), &frees, &asks, "free").coverage, 0.0);
3297    }
3298
3299    /// The guard: no decision may risk more than the gate's budget. Removing the `risk > gate` test
3300    /// from `conduct` turns this red.
3301    #[test]
3302    fn a_decision_risking_more_than_its_budget_fails_p3() {
3303        let step = |deviates: bool, gate: f64, risk: f64| Step { deviates, gate, risk, at: "x".into() };
3304        let ok = conduct(&[step(false, 0.0, 0.0), step(true, 0.5, 0.25)], Spend::default(), Spend::default(), None, 0.0);
3305        assert_eq!((ok.verdict, ok.deviations, ok.over, ok.cold), (Verdict::Pass, 1, 0, true));
3306        let over = conduct(&[step(false, 0.0, 0.0), step(true, 0.5, 0.75)], Spend::default(), Spend::default(), None, 0.0);
3307        assert_eq!((over.verdict, over.over), (Verdict::Fail, 1));
3308        let stated = |r: f64| Routed { log: crate::select::Log { risk: r, ..crate::select::Log::default() }, ..empty() };
3309        let logged = conduct(&[step(false, 0.0, 0.0), step(true, 0.5, risk(&stated(0.75), true))], Spend::default(), Spend::default(), None, 0.0);
3310        assert_eq!(logged.verdict, Verdict::Fail, "the decision's own risk is what P3 compares");
3311        assert_eq!(risk(&stated(0.75), false), 0.0, "keeping the fixed rule risks nothing");
3312        let warm = conduct(&[step(true, 0.0, 0.0)], Spend::default(), Spend::default(), None, 0.0);
3313        assert_eq!((warm.verdict, warm.cold), (Verdict::Fail, false), "cold start is the fixed rule");
3314        // No deviation puts the gate to no test: NOT TESTED; a failure still fails.
3315        let none = conduct(&[], Spend::default(), Spend::default(), None, 0.0);
3316        assert_eq!((none.verdict, none.why.as_str()), (Verdict::NotTested, "no graded check to replay"));
3317        let kept = conduct(&[step(false, 0.0, 0.0), step(false, 0.5, 0.0)], Spend::default(), Spend::default(), None, 0.0);
3318        assert_eq!(kept.verdict, Verdict::NotTested, "{kept:?}");
3319        assert!(kept.why.contains("no decision left the fixed rule"), "{}", kept.why);
3320        let bad = conduct(&[step(false, 0.0, 0.0), step(false, -1.0, 0.0)], Spend::default(), Spend::default(), None, 0.0);
3321        assert_eq!(bad.verdict, Verdict::Fail, "a negative budget fails without a deviation");
3322    }
3323
3324    /// The guard: with an engineer's budget set, the replayed pools must spend at most it per session.
3325    /// Removing the budget clause from `conduct` turns this red; without a budget, spend only shows.
3326    #[test]
3327    fn pools_spending_past_the_engineers_budget_fail_p3() {
3328        let fine = [Step { deviates: false, gate: 0.0, risk: 0.0, at: "x".into() }, Step { deviates: true, gate: 0.5, risk: 0.25, at: "y".into() }];
3329        let p3 = |budget: Option<f64>| conduct(&fine, Spend::default(), Spend::default(), budget, 24_000.0);
3330        assert_eq!(p3(None).verdict, Verdict::Pass, "no budget: reported, not gated");
3331        assert_eq!(p3(Some(24_000.0)).verdict, Verdict::Pass, "at the budget is within it");
3332        let over = p3(Some(20_000.0));
3333        assert_eq!((over.verdict, over.budget, over.pool), (Verdict::Fail, Some(20_000.0), 24_000.0));
3334        assert!(over.first[0].contains("over the collective's budget of 20000"), "{:?}", over.first);
3335        // Replayed: a budget below what the baseline's pools spend in a session fails while the gate keeps
3336        // the baseline's pool; one above it is met.
3337        let log = text(&labelled(3, 4, 13));
3338        let none = Path::new("/nonexistent/lessons.jsonl");
3339        let (tight, _) = cold_start(&log, none, Some(1.0));
3340        assert_eq!((tight.verdict, tight.budget), (Verdict::Fail, Some(1.0)), "{tight:?}");
3341        assert!(tight.pool > 1.0 && tight.pool <= 4.0 * crate::whole::TOKENS as f64, "four checks a session, none past the baseline's pool: {tight:?}");
3342        let (loose, _) = cold_start(&log, none, Some(1e12));
3343        // Met; with no deviation on this short log the gate itself is not tested.
3344        assert_eq!((loose.verdict, loose.first.len()), (Verdict::NotTested, 0), "{loose:?}");
3345    }
3346
3347    fn empty() -> Routed<'static> {
3348        Routed { ask: Vec::new(), free: Vec::new(), placebo: Vec::new(), p: Vec::new(), pf: Vec::new(), log: crate::select::Log::default() }
3349    }
3350
3351    #[test]
3352    fn the_cold_start_replay_of_a_fixed_log_is_the_fixed_rule_and_counts_spend() {
3353        let mut stop = graded("s", 1, &["A", "B"], &[3, 1]);
3354        stop.channel = "stop".into();
3355        stop.tokens = 1200;
3356        let log = [stop, graded("s", 2, &["C", "D"], &[2, 1]), graded("t", 3, &["E"], &[3])];
3357        let (p3, targets) = cold_start(&text(&log), Path::new("/nonexistent/lessons.jsonl"), None);
3358        assert_eq!((p3.verdict, p3.checks, p3.deviations, p3.cold, p3.budget), (Verdict::NotTested, 3, 0, true, None), "no fit, no deviation: {p3:?}");
3359        assert_eq!(p3.policy, Spend { checks: 3, questions: 3, blocks: 1, free: 2, tokens: 1200, usd: 0.0 });
3360        assert_eq!(p3.policy, p3.fixed);
3361        assert_eq!(targets[1], (vec![0.0, 0.0], vec![1.0, 1.0]));
3362    }
3363
3364    /// A record logs the applies its session already had (`nofree`), and every pass decides its check
3365    /// under them as the hook did: such a lesson is never handed over free, by the fixed rule (P0's
3366    /// reference and the logged decision), by the replayed policy (P1), or in the cold start (P3's spend).
3367    #[test]
3368    fn every_pass_replays_a_check_under_the_lessons_its_session_had() {
3369        let none = Path::new("/nonexistent/lessons.jsonl");
3370        // A live check without a 3, its session already had A: the fixed rule hands over only B.
3371        let mut o = graded("s", 1, &["A", "B"], &[2, 1]);
3372        o.check = crate::select::check_id("s", "live", 1, 1);
3373        o.policy = "fixed".into();
3374        o.free = vec!["B".into()];
3375        o.pf = vec![0.0, 1.0];
3376        o.nofree = vec!["A".into()];
3377        let pass = as_logged(&text(std::slice::from_ref(&o)), none);
3378        assert_eq!(pass.checks[0].fixed, (Vec::<String>::new(), vec!["B".to_string()]), "the fixed rule for a session that had A");
3379        assert_eq!((pass.p0.verdict, pass.p1.verdict), (Verdict::Pass, Verdict::Pass), "{:?} {:?}", pass.p0.first, pass.p1.first);
3380        let (p3, targets) = cold_start(&text(std::slice::from_ref(&o)), none, None);
3381        assert_eq!((p3.policy.free, p3.fixed.free, p3.deviations), (1, 1, 0), "{p3:?}");
3382        assert_eq!(targets[0], (vec![0.0, 0.0], vec![0.0, 1.0]));
3383        // The same decision without its `nofree` is no fixed-rule decision: the rule would hand A over too.
3384        o.nofree.clear();
3385        let pass = as_logged(&text(std::slice::from_ref(&o)), none);
3386        assert_eq!((pass.p0.verdict, pass.p1.verdict), (Verdict::Fail, Verdict::Fail));
3387    }
3388
3389    /// The guard: the replay's pools are chosen under the loosest latency limit, as `price` replays
3390    /// them, so a pool a hook's limit would allow above the baseline counts against the engineer's
3391    /// budget. Under no limit (None) every pool above the baseline is forbidden and P3 would understate
3392    /// the spend.
3393    #[test]
3394    fn the_replays_pools_may_grow_where_a_hooks_limit_would_let_them() {
3395        let pool = policy::PoolFit {
3396            yields: vec![(50.0, 1.0); 8],
3397            per_lesson: 400.0,
3398            latency: (1000.0, 0.1, 500.0),
3399            latency_n: 30,
3400            latency_reach: 24_000,
3401            eta: 0.0,
3402            slope: (0.0, 0.0),
3403            not: (-3.0, 0.0),
3404        };
3405        let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: 1.0, ..summary::Gate::default() };
3406        let s = Summary { fit: Fit { pool, ..Fit::default() }, gate, ..Summary::default() };
3407        let baseline = crate::whole::TOKENS;
3408        assert!(pool_of(&s, "x") > baseline, "a larger pool is worth more and its latency fits: {}", pool_of(&s, "x"));
3409        assert_eq!(crate::select::pool_tokens_under(Policy::Learned, &s, None, "x"), baseline, "without a limit, never above the baseline");
3410        // Closed gate: the baseline, as in production.
3411        let closed = Summary { gate: summary::Gate::default(), ..s.clone() };
3412        assert_eq!(pool_of(&closed, "x"), baseline);
3413    }
3414
3415    /// The cold start folds the replayed decision as the hook would have logged it: its own routes,
3416    /// propensities, placebo chance, source and question-count draw, on the logged check's applies and had lessons.
3417    #[test]
3418    fn the_cold_start_folds_the_replayed_decision_with_its_own_propensities() {
3419        let mut o = graded("s", 1, &["A", "B", "C"], &[2, 1, 1]);
3420        (o.policy, o.source, o.placebo, o.psi, o.nofree) = ("fixed".into(), "fixed".into(), vec!["C".into()], 0.5, vec!["A".into()]);
3421        let ls: Vec<Challenge> = ["A", "B", "C"].iter().map(|id| Challenge { id: id.to_string(), ..Default::default() }).collect();
3422        let r = Routed {
3423            ask: vec![&ls[1]],
3424            free: Vec::new(),
3425            placebo: vec![&ls[2]],
3426            p: vec![0.0, 1.0, 0.0],
3427            pf: vec![0.0; 3],
3428            log: crate::select::Log {
3429                policy: "learned",
3430                source: policy::EXPERIMENT,
3431                fit: "9-abcdef01".into(),
3432                psi: 1.0,
3433                xk: Some(2),
3434                xs: Some(1),
3435                ..crate::select::Log::default()
3436            },
3437        };
3438        let d = decided(o.clone(), &r);
3439        assert_eq!((d.chosen, d.free, d.placebo, d.p, d.psi), (vec!["B".to_string()], Vec::new(), vec!["C".to_string()], vec![0.0, 1.0, 0.0], 1.0));
3440        assert_eq!((d.policy.as_str(), d.source.as_str(), d.fit.as_str()), ("learned", "experiment", "9-abcdef01"));
3441        assert_eq!((d.xk, d.xs), (Some(2), Some(1)), "the test's k and s, which the summary folds");
3442        assert_eq!((d.lessons, d.grades, d.nofree, d.check), (o.lessons, o.grades, o.nofree, o.check), "the logged check's");
3443    }
3444
3445    /// Leave-future-out over time: a label is known before a check at its value then. A veto after
3446    /// the check, or a stronger verdict, is a result from after it and reaches only later checks.
3447    #[test]
3448    fn a_later_veto_reaches_no_prediction_before_it() {
3449        let mut a = graded("s", 10, &["A"], &[1]);
3450        a.check = "0000000000aa".into();
3451        let later = |ms: i64, lesson: &str| {
3452            let mut c = graded("s", ms, &[lesson], &[1]);
3453            c.check = format!("{ms:012x}");
3454            c
3455        };
3456        let log = [
3457            a,
3458            Outcome { check: "0000000000aa".into(), ..result(outcome::CHANGED, "s", "A", 15, "queued") },
3459            later(50, "B"),
3460            Outcome { check: format!("{:012x}", 50), ..result(outcome::IGNORED, "s", "B", 55, "queued") },
3461            result(outcome::VETOED, "s", "A", 100, ""),
3462            later(200, "C"),
3463            Outcome { check: format!("{:012x}", 200), ..result(outcome::IGNORED, "s", "C", 205, "queued") },
3464        ];
3465        let checks = checks_of(&log);
3466        let (ls, _) = labels(&checks, &log);
3467        assert_eq!(ls[0].known, [(15, 1.0), (100, 0.0)], "changed, then vetoed");
3468        assert_eq!((ls[0].y, ls[0].at(15), ls[0].at(50), ls[0].at(200)), (0.0, None, Some((15, 1.0)), Some((100, 0.0))), "{:?}", ls[0]);
3469        // Grade only before B knows A as changed, before C as not.
3470        let past = past_counts(&ls);
3471        assert_eq!((past[1][1][FREE], past[2][1][FREE]), ((1, 1), (2, 0)), "{past:?}");
3472        // So does the value model's training log.
3473        let label_of = |s: &Summary, lesson: &str| {
3474            let at = s.lessons.iter().position(|l| l == lesson).unwrap() as u32;
3475            s.applies.iter().find(|x| x.lesson == at).map(|x| x.label)
3476        };
3477        let all: Vec<&Label> = ls.iter().collect();
3478        assert_eq!(label_of(&known(&checks, &all[..1], 50), "A"), Some(summary::Label::Changed));
3479        assert_eq!(label_of(&known(&checks, &all[..2], 200), "A"), Some(summary::Label::Ignored));
3480        // An ignored verdict then a change: unknown before the first, then 0, then 1.
3481        assert_eq!(values(&[(5, 0), (9, 1)], &[]), [(5, 0.0), (9, 1.0)]);
3482        assert_eq!(values(&[(5, 1)], &[(5, 3)]), [(5, 0.0)], "results at the same ms land together");
3483        assert_eq!(values(&[(5, 1)], &[(2, 2)]), [(5, 0.0)], "a correction after the check and before the verdict outranks it");
3484    }
3485
3486    #[test]
3487    fn eval_checks_read_graded_lines_channels_and_cost() {
3488        let ctx = "#borg-predict-cost 0.0500\n#borg-graded n=2 L1:3:0,L2:1:1\n#borg-p L1=1,L2=1\n#borg-asked L1,L2\n#borg-predict-cost 0.0700\n#borg-graded n=1 La:b:2:0\n#borg-queued La:b\n#borg-graded n=0 \n";
3489        let cost =
3490            "live usd=0.05 lessons=3\ngraded applied=2 ask=2 free=0 dropped\ngraded applied=1 ask=0 free=1 queued\ngraded applied=0 ask=0 free=0 dropped\n";
3491        let cs = eval_checks("s", ctx, cost);
3492        assert_eq!(cs.iter().map(|c| c.channel).collect::<Vec<_>>(), ["live", "stop", "stop"]);
3493        assert_eq!(cs.iter().map(|c| c.usd).collect::<Vec<_>>(), [0.05, 0.07, 0.0], "each check's own call");
3494        let applies: Vec<Vec<(String, u8)>> = cs.into_iter().map(|c| c.applies).collect();
3495        assert_eq!(applies, [vec![("L1".into(), 3), ("L2".into(), 1)], vec![("La:b".into(), 2)], vec![]], "an id may hold a colon");
3496        // Without an aligned cost log, a #borg-live delivery after the check marks it live.
3497        let cs = eval_checks("s", ctx, "");
3498        assert_eq!(cs.iter().map(|c| c.channel).collect::<Vec<_>>(), ["stop", "stop", "stop"]);
3499        let live = "#borg-graded n=1 L1:3:0\n#borg-asked L1\n#borg-live L1 ms=5\n#borg-graded n=1 L2:3:0\n#borg-asked L2\n";
3500        assert_eq!(eval_checks("s", live, "").iter().map(|c| c.channel).collect::<Vec<_>>(), ["live", "stop"]);
3501    }
3502
3503    #[test]
3504    fn recall_counts_a_run_whose_check_delivers_a_right_lesson() {
3505        let check = |applies: &[(&str, u8)]| EvalCheck {
3506            session: "s".into(),
3507            channel: "stop",
3508            applies: applies.iter().map(|(i, g)| (i.to_string(), *g)).collect(),
3509            usd: 0.1,
3510        };
3511        let runs = vec![
3512            EvalRun { case: "c1".into(), checks: vec![check(&[("X", 1)]), check(&[("R", 2)])] }, // R handed over free
3513            EvalRun { case: "c1".into(), checks: vec![check(&[("X", 3)])] },
3514            EvalRun { case: "c2".into(), checks: vec![check(&[("R2", 3), ("X", 1)])] }, // a block asks both
3515            EvalRun { case: "c2".into(), checks: vec![check(&[("R2", 1)])] },
3516        ];
3517        let right: BTreeMap<String, BTreeSet<String>> =
3518            [("c1".to_string(), BTreeSet::from(["R".to_string()])), ("c2".to_string(), BTreeSet::from(["R2".to_string()]))].into();
3519        let none = Path::new("/nonexistent/lessons.jsonl");
3520        let base = recalls(&runs, &right, None, 1, none);
3521        assert_eq!((base.per_run.as_slice(), base.deviations), (&[1.0, 0.0, 1.0, 1.0][..], 0));
3522        assert_eq!(base.spend, [3.0 / 5.0, 3.0 / 5.0, 2.0 / 5.0], "questions, free and blocks per check");
3523        // Expected over draws: a policy that delivers on half its seeds recalls a one-check run half the time.
3524        let coin = |u: &[Unit], _: &EvalCheck, seed: u64| vec![if seed.is_multiple_of(2) { Route::Free } else { Route::Not }; u.len()];
3525        let half = recalls(&runs, &right, Some(&coin), conscience::P_DRAWS, none);
3526        assert!(half.per_run.iter().zip(&base.per_run).all(|(h, b)| (h - 0.5 * b).abs() < 0.1), "half of each run the fixed rule recalls: {:?}", half.per_run);
3527        assert_eq!(half.deviations, 5, "every check has a draw off the fixed rule");
3528        // A case short of a seed: not decided, no arm judged.
3529        let all = |u: &[Unit], _: &EvalCheck, _: u64| vec![Route::Ask; u.len()];
3530        let short = against_baseline(&runs[..3], &right, Arms { prior: &all, posterior: &all, test: &all }, none);
3531        assert_eq!((short.verdict, short.p4a.is_none()), (Verdict::NotRun, true), "{short:?}");
3532    }
3533
3534    /// P4's three arms (rules v2), each against its own bar on planted draws where it can fail: P4a
3535    /// fails on any deviation under the prior and tests nothing without an apply; P4b fails below the
3536    /// recall bar and is NOT TESTED where it is the fixed rule, which still meets the default rule; P4c
3537    /// fails when a run's recall moves and is NOT TESTED when no draw leaves the fixed rule. `verdict`
3538    /// is theirs by the default rule.
3539    #[test]
3540    fn p4_judges_each_arm_against_its_own_bar() {
3541        let check = |applies: &[(&str, u8)]| EvalCheck {
3542            session: "s".into(),
3543            channel: "live",
3544            applies: applies.iter().map(|(i, g)| (i.to_string(), *g)).collect(),
3545            usd: 0.0,
3546        };
3547        let runs = vec![
3548            EvalRun { case: "c1".into(), checks: vec![check(&[("X", 1)]), check(&[("R", 2)])] },
3549            EvalRun { case: "c1".into(), checks: vec![check(&[("X", 3)])] },
3550            EvalRun { case: "c2".into(), checks: vec![check(&[("R2", 3), ("X", 1)])] },
3551            EvalRun { case: "c2".into(), checks: vec![check(&[("R2", 1)])] },
3552        ];
3553        let right: BTreeMap<String, BTreeSet<String>> =
3554            [("c1".to_string(), BTreeSet::from(["R".to_string()])), ("c2".to_string(), BTreeSet::from(["R2".to_string()]))].into();
3555        let none = Path::new("/nonexistent/lessons.jsonl");
3556        let fixed = |u: &[Unit], _: &EvalCheck, _: u64| policy::fixed_routes(u, &[]);
3557        let never = |u: &[Unit], _: &EvalCheck, _: u64| vec![Route::Not; u.len()];
3558        let all = |u: &[Unit], _: &EvalCheck, _: u64| vec![Route::Ask; u.len()];
3559        // As the test: what the fixed rule hands over is asked on odd seeds, so nothing is withheld.
3560        let test = |u: &[Unit], _: &EvalCheck, seed: u64| {
3561            policy::fixed_routes(u, &[]).into_iter().map(|r| if r == Route::Free && seed % 2 == 1 { Route::Ask } else { r }).collect()
3562        };
3563        let parts = |p: &P4| [&p.p4a, &p.p4b, &p.p4c].map(|x| x.as_ref().map(|x| x.verdict));
3564        // The decision rule is the fixed rule under both fits, the test moves routes but no recall.
3565        let met = against_baseline(&runs, &right, Arms { prior: &fixed, posterior: &fixed, test: &test }, none);
3566        assert_eq!(parts(&met), [Some(Verdict::Pass), Some(Verdict::NotTested), Some(Verdict::Pass)], "{met:?}");
3567        assert_eq!((met.verdict, met.why.as_str(), met.applying), (Verdict::Pass, "", 5), "P4b NOT TESTED meets the default rule: {met:?}");
3568        let c = met.p4c.as_ref().unwrap();
3569        assert_eq!((c.arm.deviations, c.arm.same), (3, 4), "the three checks that hand a lesson over: {c:?}");
3570        assert!(met.p4b.as_ref().unwrap().why.contains("so it is the fixed rule"), "{met:?}");
3571        // P4a: a deviation under the prior fails it and the default rule, and is listed.
3572        let a = against_baseline(&runs, &right, Arms { prior: &all, posterior: &fixed, test: &test }, none);
3573        assert_eq!((a.verdict, parts(&a)[0], a.why.as_str()), (Verdict::Fail, Some(Verdict::Fail), "P4a FAIL"), "{a:?}");
3574        let pa = a.p4a.as_ref().unwrap();
3575        assert_eq!((pa.arm.deviations, pa.why.as_str()), (3, "3 of 5 checks that apply a lesson decide otherwise than the fixed rule"), "{pa:?}");
3576        assert!(pa.arm.first[0].starts_with("case c1 session s check 0 (live): ask against the fixed rule's free"), "{:?}", pa.arm.first);
3577        // P4b: losing the right lessons misses the bar; keeping them passes.
3578        let b = against_baseline(&runs, &right, Arms { prior: &fixed, posterior: &never, test: &test }, none);
3579        assert_eq!((b.verdict, parts(&b)[1]), (Verdict::Fail, Some(Verdict::Fail)), "{b:?}");
3580        assert_eq!(b.p4b.as_ref().unwrap().arm.delta.as_ref().unwrap().value, Some(-0.75));
3581        let b = against_baseline(&runs, &right, Arms { prior: &fixed, posterior: &all, test: &test }, none);
3582        assert_eq!((b.verdict, parts(&b)[1]), (Verdict::Pass, Some(Verdict::Pass)), "{b:?}");
3583        // P4c: a test that withholds fails; one that never leaves the fixed rule tests nothing, and the
3584        // default rule is not met.
3585        let c = against_baseline(&runs, &right, Arms { prior: &fixed, posterior: &fixed, test: &never }, none);
3586        assert_eq!((c.verdict, parts(&c)[2]), (Verdict::Fail, Some(Verdict::Fail)), "{c:?}");
3587        assert_eq!(c.p4c.as_ref().unwrap().why, "3 of 4 runs recall otherwise than the fixed rule");
3588        let c = against_baseline(&runs, &right, Arms { prior: &fixed, posterior: &fixed, test: &fixed }, none);
3589        assert_eq!((c.verdict, parts(&c)[2], c.why.as_str()), (Verdict::NotTested, Some(Verdict::NotTested), "P4c NOT TESTED"), "{c:?}");
3590        // No check applies a lesson: P4a tests nothing.
3591        let bare: Vec<EvalRun> = runs.iter().map(|r| EvalRun { case: r.case.clone(), checks: vec![check(&[])] }).collect();
3592        let n = against_baseline(&bare, &right, Arms { prior: &fixed, posterior: &fixed, test: &fixed }, none);
3593        assert_eq!((n.applying, parts(&n)[0], n.verdict), (0, Some(Verdict::NotTested), Verdict::NotTested), "{n:?}");
3594    }
3595
3596    /// P4's baseline (`recalls` without a policy: select/policy.rs `fixed_routes`) is P0's reference
3597    /// (conscience.rs `graded_without`, no cap) by position, on a grade table with and without lessons
3598    /// the session had: the two checks judge against one fixed rule.
3599    #[test]
3600    fn the_recall_baseline_is_the_fixed_rule_p0_checks() {
3601        // P0's reference as routes: each copy of a lesson by its own place in the applies.
3602        fn reference(applied: &[(&Challenge, u8)], had: &[String]) -> Vec<Route> {
3603            let (ask, free) = conscience::graded_without(applied, None, had);
3604            let held = |v: &[&Challenge], x: &Challenge| v.iter().any(|y| std::ptr::eq(*y, x));
3605            applied
3606                .iter()
3607                .map(|(x, _)| match (held(&ask, x), held(&free, x)) {
3608                    (true, _) => Route::Ask,
3609                    (_, true) => Route::Free,
3610                    _ => Route::Not,
3611                })
3612                .collect()
3613        }
3614        // A check's applies as `recalls` reads them.
3615        fn challenges(applies: &[(String, u8)]) -> Vec<Challenge> {
3616            applies.iter().map(|(id, _)| Challenge { id: id.clone(), ..Default::default() }).collect()
3617        }
3618        fn applied<'a>(cs: &'a [Challenge], applies: &[(String, u8)]) -> Vec<(&'a Challenge, u8)> {
3619            cs.iter().zip(applies.iter().map(|(_, g)| *g)).collect()
3620        }
3621        let lessons: Vec<String> = (0..12).map(|i| format!("L{i}")).collect();
3622        let named = |gs: &[u8]| -> Vec<(String, u8)> { gs.iter().zip(&lessons).map(|(g, id)| (id.clone(), *g)).collect() };
3623        let twice = |gs: &[u8], again: u8| [named(gs), vec![(lessons[0].clone(), again)]].concat();
3624        let table = [
3625            Vec::new(),
3626            named(&[3]),
3627            named(&[1, 3, 2]),                            // a 3 blocks: every 3, 2 and 1
3628            named(&[1, 2, 1]),                            // no 3: handed over free
3629            named(&[1, 2, 3, 1, 2, 1, 1, 2, 1, 1, 2, 3]), // a block past the cap: the 3s and 2s, then the first 1s
3630            named(&[2, 1, 1, 2, 1, 1, 2, 1, 1, 2, 1, 1]), // free past the cap: the 2s, then the first 1s
3631            twice(&[1; 10], 1),                           // L0 named again past the cap: that copy waits
3632            twice(&[1, 2], 3),                            // L0 named again as a 3: both copies asked
3633        ];
3634        let hads: [Vec<String>; 4] = [Vec::new(), vec![lessons[0].clone()], vec![lessons[1].clone(), "Z".into()], lessons.clone()];
3635        let mut moved = 0;
3636        for row in &table {
3637            let cs = challenges(row);
3638            let applied = applied(&cs, row);
3639            for had in &hads {
3640                let want = reference(&applied, had);
3641                assert_eq!(policy::fixed_routes(&crate::select::units(&applied), had), want, "{row:?} had {had:?}");
3642                moved += usize::from(want != reference(&applied, &[]));
3643            }
3644        }
3645        assert!(moved > 0, "a lesson the session had changes some row's routes");
3646        // `recalls` decides its baseline by that rule: a policy that is P0's reference never deviates
3647        // from it and recalls as it does.
3648        let checks = table.iter().map(|row| EvalCheck { session: "s".into(), channel: "stop", applies: row.clone(), usd: 0.0 }).collect();
3649        let runs = [EvalRun { case: "c".into(), checks }];
3650        let right: BTreeMap<String, BTreeSet<String>> = [("c".to_string(), BTreeSet::from([lessons[11].clone()]))].into();
3651        let p0 = |_: &[Unit], c: &EvalCheck, _: u64| reference(&applied(&challenges(&c.applies), &c.applies), &[]);
3652        let none = Path::new("/nonexistent/lessons.jsonl");
3653        let (base, same) = (recalls(&runs, &right, None, 1, none), recalls(&runs, &right, Some(&p0), conscience::P_DRAWS, none));
3654        assert_eq!((same.deviations, &same.per_run, same.spend), (0, &base.per_run, base.spend));
3655    }
3656
3657    /// A labelled log under the fixed rule: `sessions` sessions of `checks` checks (with ids, every
3658    /// fourth at the stop), each asked or free apply delivered and judged (changed more often the higher
3659    /// its grade), each session settled at its end as `borg learn` settles it.
3660    fn labelled(sessions: usize, checks: usize, seed: u64) -> Vec<Outcome> {
3661        let mut rng = memory::Rng::new(seed);
3662        let (mut log, mut ms) = (Vec::new(), 0);
3663        for s in 0..sessions {
3664            let session = format!("s{s}");
3665            for c in 0..checks {
3666                ms += 10;
3667                let n = 1 + (rng.next_u64() % 4) as usize;
3668                let mut lessons: Vec<String> = Vec::new();
3669                while lessons.len() < n {
3670                    let l = format!("L{}", rng.next_u64() % 12);
3671                    if !lessons.contains(&l) {
3672                        lessons.push(l);
3673                    }
3674                }
3675                let grades: Vec<u8> = (0..n).map(|_| 1 + (rng.next_u64() % 3) as u8).collect();
3676                let mut o = graded(&session, ms, &lessons.iter().map(String::as_str).collect::<Vec<_>>(), &grades);
3677                o.channel = if c % 4 == 3 { "stop" } else { "live" }.into();
3678                o.check = crate::select::check_id(&session, &o.channel, ms, 1);
3679                o.policy = "fixed".into();
3680                let cs: Vec<Challenge> = lessons.iter().map(|id| Challenge { id: id.clone(), ..Default::default() }).collect();
3681                o.free = ids(&conscience::graded_capped(&cs.iter().zip(grades.iter().copied()).collect::<Vec<_>>(), None).1);
3682                o.pf = holds(&o.lessons, &o.free);
3683                // Per delivered apply: its lesson, delivery route, verdict channel and grade.
3684                let routed: Vec<(String, &str, String, u8)> = o
3685                    .lessons
3686                    .iter()
3687                    .zip(&grades)
3688                    .filter_map(|(l, g)| match (o.chosen.contains(l), o.free.contains(l)) {
3689                        (true, _) => Some((l.clone(), outcome::ROUTE_ASKED, o.channel.clone(), *g)),
3690                        (_, true) => Some((l.clone(), outcome::ROUTE_FREE, "queued".to_string(), *g)),
3691                        _ => None,
3692                    })
3693                    .collect();
3694                let check = o.check.clone();
3695                log.push(o);
3696                for (l, route, ch, g) in routed {
3697                    ms += 1;
3698                    log.push(Outcome { check: check.clone(), route: route.into(), ..result(outcome::DELIVERY, &session, &l, ms, "") });
3699                    let changed = rng.unit() < [0.0, 0.15, 0.35, 0.5][usize::from(g)];
3700                    let kind = if changed { outcome::CHANGED } else { outcome::IGNORED };
3701                    log.push(Outcome { check: check.clone(), judge: 2, ..result(kind, &session, &l, ms, &ch) });
3702                }
3703            }
3704            ms += 10;
3705            log.push(Outcome { lessons: Vec::new(), reason: outcome::UNJUDGED_NONE.into(), ..result(outcome::NOT_CORRECTED, &session, "", ms, "") });
3706        }
3707        log
3708    }
3709
3710    /// How a planted question-count test's lessons fare (`tested_log`): lesson j (A and B 2s, C a 1)
3711    /// at the drawn count s changes the work with chance `change(j, s, asked)`; handed over, it is
3712    /// delivered with chance `delivered` (else dropped at delivery, never judged); delivered, the judge
3713    /// gives a verdict with chance `seen(asked)` (else it could not tell).
3714    struct Plant {
3715        change: fn(usize, usize, bool) -> f64,
3716        delivered: f64,
3717        seen: fn(bool) -> f64,
3718    }
3719
3720    /// No dilution: an asked 2 changes the work 0.8 of the time at every count, a free 2 0.2, a 1
3721    /// never; every lesson delivered and judged.
3722    const UNDILUTED: Plant = Plant {
3723        change: |j, _, ask| {
3724            if j == 2 {
3725                0.0
3726            } else if ask {
3727                0.8
3728            } else {
3729                0.2
3730            }
3731        },
3732        delivered: 1.0,
3733        seen: |_| 1.0,
3734    };
3735
3736    /// A planted question-count test: `sessions` sessions of two live checks of two 2s and a 1 (no 3,
3737    /// so k = 3), the draw s the check's number mod 4, the top s asked in the fixed rule's order and the
3738    /// rest handed over, with the draw's propensities (position i asked with chance (k − i)/(k + 1)),
3739    /// each lesson faring as `plant` says, drawn from `seed`.
3740    fn tested_log(sessions: usize, plant: &Plant, seed: u64) -> Vec<Outcome> {
3741        let (lessons, grades) = (["A", "B", "C"], [2u8, 2, 1]);
3742        let (mut log, mut n, mut ms) = (Vec::new(), 0usize, 0);
3743        let mut rng = memory::Rng::new(seed);
3744        for x in 0..sessions {
3745            let session = format!("t{x}");
3746            for _ in 0..2 {
3747                let s = n % 4;
3748                n += 1;
3749                ms += 10;
3750                let check = format!("{n:012x}");
3751                let owned = |v: &[&str]| v.iter().map(|l| l.to_string()).collect::<Vec<_>>();
3752                log.push(Outcome {
3753                    chosen: owned(&lessons[..s]),
3754                    free: owned(&lessons[s..]),
3755                    check: check.clone(),
3756                    policy: "learned".into(),
3757                    source: if s == 0 { policy::EXPERIMENT_CONTROL } else { policy::EXPERIMENT }.into(),
3758                    xk: Some(3),
3759                    xs: Some(s as u32),
3760                    p: vec![0.75, 0.5, 0.25],
3761                    pf: vec![0.25, 0.5, 0.75],
3762                    ..graded(&session, ms, &lessons, &grades)
3763                });
3764                for (j, l) in lessons.iter().enumerate() {
3765                    let ask = j < s;
3766                    let (changed, delivered, seen) =
3767                        (rng.unit() < (plant.change)(j, s, ask), ask || rng.unit() < plant.delivered, rng.unit() < (plant.seen)(ask));
3768                    let channel = if ask { "live" } else { "queued" };
3769                    if !delivered {
3770                        let route = outcome::ROUTE_DROPPED_CHARS.into();
3771                        log.push(Outcome { check: check.clone(), route, ..result(outcome::DELIVERY, &session, l, ms + 1, channel) });
3772                        continue;
3773                    }
3774                    let kind = match (seen, changed) {
3775                        (false, _) => outcome::UNCLEAR,
3776                        (true, true) => outcome::CHANGED,
3777                        (true, false) => outcome::IGNORED,
3778                    };
3779                    log.push(Outcome { check: check.clone(), judge: 2, ..result(kind, &session, l, ms + 1, channel) });
3780                }
3781            }
3782        }
3783        log
3784    }
3785
3786    fn readout_of(log: &[Outcome]) -> TestReadout {
3787        test_readout(&summary::fold(&text(log), Summary::default()))
3788    }
3789
3790    /// The question-count test's readout (rules v2) reads the summary's fold: not due, and then no
3791    /// estimate, before `TEST_SESSIONS` sessions with a drawn check (or `TEST_CHECKS` checks); due,
3792    /// dilution against one asked for each m up to k, and asking a 2 against handing it over by the
3793    /// logged propensities, which finds the planted gain of asking; over the drawn checks the summary
3794    /// still holds, which it says.
3795    #[test]
3796    fn the_question_count_readout_waits_until_due_and_then_finds_the_planted_gain_of_asking() {
3797        let early = readout_of(&tested_log(TEST_SESSIONS as usize - 1, &UNDILUTED, 7));
3798        assert_eq!((early.due, early.checks, early.control, early.sessions), (false, 58, 15, 29), "{early:?}");
3799        assert!(early.by_asked.is_empty() && early.ask_free.is_none(), "nothing read before it is due: {early:?}");
3800        let log = tested_log(TEST_SESSIONS as usize, &UNDILUTED, 7);
3801        let s = summary::fold(&text(&log), Summary::default());
3802        let r = test_readout(&s);
3803        assert_eq!((r.due, r.checks, r.control, r.refused, r.sessions, r.kept), (true, 60, 15, 0, 30, 60), "{r:?}");
3804        // k = 3 at every check: m of 2 and 3 against 1, each over all 60 drawn checks.
3805        let by: Vec<(u16, usize)> = r.by_asked.iter().map(|(m, e)| (*m, e.n)).collect();
3806        assert_eq!(by, [(2, 60), (3, 60)], "{r:?}");
3807        // Asked, a 2 changes the work 0.8 of the time, handed over 0.2: a gain of about 0.6.
3808        let gain = r.ask_free.as_ref().unwrap();
3809        assert_eq!(gain.n, 120, "every 2: {gain:?}");
3810        assert!(gain.value.is_some_and(|v| (v - 0.6).abs() < 0.15) && gain.lo > 0.0, "{gain:?}");
3811        // A draw the gate refused is randomised too, its routes as logged: read with the others.
3812        let mut refused = log.clone();
3813        let g = refused.iter_mut().find(|o| o.kind == outcome::GRADED && o.xs == Some(3)).unwrap();
3814        g.source = policy::GATE.into();
3815        let rr = readout_of(&refused);
3816        assert_eq!((rr.checks, rr.refused, rr.by_asked.iter().map(|(m, e)| (*m, e.n)).collect::<Vec<_>>()), (60, 1, by.clone()), "{rr:?}");
3817        // Compaction drops the oldest checks, never the counts: due on all 60, estimated over the rest.
3818        let mut compacted = s.clone();
3819        compacted.checks.drain(..10);
3820        let rc = test_readout(&compacted);
3821        assert_eq!(
3822            (rc.due, rc.checks, rc.kept, rc.by_asked.iter().map(|(m, e)| (*m, e.n)).collect::<Vec<_>>()),
3823            (true, 60, 50, vec![(2, 50), (3, 50)]),
3824            "{rc:?}"
3825        );
3826        let shown = render(&evaluate(Input {
3827            text: &text(&log),
3828            lessons: Path::new("/nonexistent/lessons.jsonl"),
3829            summary: Summary::default(),
3830            replay: true,
3831            eval: None,
3832        }));
3833        assert!(
3834            shown.contains("question-count test readout (rules v2, amendment 2026-10-31): due (60 checks that drew a count in 30 sessions with one, due at 300 checks in 15 sessions or 30 sessions; 15 of them the control arm, 0 refused by the gate)\n")
3835                && shown.contains("  estimated over the 60 drawn checks the summary still holds ")
3836                && shown.contains("  dilution, 2 asked − 1 asked: the test's top lesson's change rate ")
3837                && shown.contains("  grade 2 asked − handed over: "),
3838            "{shown}"
3839        );
3840    }
3841
3842    /// The readout's due rule amended 2026-10-31: `TEST_CHECKS` drawn checks need `TEST_MIN_SESSIONS`
3843    /// sessions with one beside them (an open-source store's 167 checks came 164 from one session);
3844    /// `TEST_SESSIONS` sessions alone still suffice.
3845    #[test]
3846    fn the_question_count_readout_needs_sessions_beside_its_checks() {
3847        let at = |checks: u64, sessions: u32| {
3848            let mut s = Summary::default();
3849            s.experiment.ran = vec![(3, 1, checks)];
3850            s.experiment.sessions = sessions;
3851            test_readout(&s).due
3852        };
3853        assert!(!at(300, TEST_MIN_SESSIONS - 1), "300 checks in 14 sessions: not due");
3854        assert!(at(300, TEST_MIN_SESSIONS) && at(1_000, 20), "300 checks in 15 sessions");
3855        assert!(!at(299, TEST_SESSIONS - 1), "neither");
3856        assert!(at(0, TEST_SESSIONS), "30 sessions alone");
3857    }
3858
3859    /// Two collectives' tests pooled (amendment 2026-10-31): neither due alone (16 sessions each), due
3860    /// pooled; intention to treat over every collective's units, a refused draw counted at its drawn
3861    /// number and shown in the share played; equal effects pool, opposite ones are flagged.
3862    #[test]
3863    fn the_pooled_readout_is_stratified_by_collective_and_flags_collectives_that_differ() {
3864        const REVERSED: Plant = Plant {
3865            change: |j, _, ask| {
3866                if j == 2 {
3867                    0.0
3868                } else if ask {
3869                    0.2
3870                } else {
3871                    0.8
3872                }
3873            },
3874            delivered: 1.0,
3875            seen: |_| 1.0,
3876        };
3877        let mut team = tested_log(16, &UNDILUTED, 7);
3878        let g = team.iter_mut().find(|o| o.kind == outcome::GRADED && o.xs == Some(3)).unwrap();
3879        g.source = policy::GATE.into();
3880        // The other collective's sessions are other sessions (`tested_log` names them t0, t1, … in both).
3881        let elsewhere = |log: Vec<Outcome>| log.into_iter().map(|o| Outcome { session: format!("o{}", o.session), ..o }).collect::<Vec<_>>();
3882        let open = elsewhere(tested_log(16, &UNDILUTED, 8));
3883        let p = pooled(&[("team".into(), text(&team)), ("open".into(), text(&open))]);
3884        assert_eq!((p.due, p.checks, p.sessions), (true, 64, 32), "{p:?}");
3885        // The same 16 drone sessions drawing in both collectives are 16 sessions: 64 checks in them are
3886        // not due, as they would be counted per collective (32).
3887        let same = pooled(&[("team".into(), text(&team)), ("open".into(), text(&tested_log(16, &UNDILUTED, 8)))]);
3888        assert_eq!((same.due, same.checks, same.sessions), (false, 64, 16), "{same:?}");
3889        assert!(same.strata.iter().all(|s| s.readout.sessions == 16), "each collective still counts its own: {same:?}");
3890        assert!(p.strata.iter().all(|s| !s.readout.due && s.readout.ask_free.is_some()), "each estimated once the pool is due: {p:?}");
3891        assert_eq!(p.strata[0].played.iter().map(|x| (x.drawn, x.checks, x.played)).collect::<Vec<_>>(), [(0, 8, 8), (1, 8, 8), (2, 8, 8), (3, 8, 7)]);
3892        assert_eq!(p.played.iter().map(|x| (x.drawn, x.checks, x.played)).collect::<Vec<_>>(), [(0, 16, 16), (1, 16, 16), (2, 16, 16), (3, 16, 15)]);
3893        let gain = p.ask_free.as_ref().unwrap();
3894        let each: usize = p.strata.iter().map(|s| s.readout.ask_free.as_ref().unwrap().n).sum();
3895        assert_eq!(gain.n, each, "every collective's 2s: {gain:?}");
3896        assert!(gain.value.is_some_and(|v| (v - 0.6).abs() < 0.15) && gain.lo > 0.0 && gain.lo < gain.hi, "{gain:?}");
3897        assert_eq!(p.by_asked.iter().map(|(m, e)| (*m, e.n)).collect::<Vec<_>>(), [(2, 64), (3, 64)]);
3898        assert!(p.differ.is_empty(), "{:?}", p.differ);
3899        let shown = render_pooled(&p);
3900        assert!(
3901            shown.starts_with("question-count test readout (rules v2, amendment 2026-10-31), pooled over 2 collectives, stratified by collective: due (64 checks that drew a count in 32 sessions with one, ")
3902                && shown.contains("\n  team: 32 drawn in 16 sessions (8 of them the control arm, 1 refused by the gate); played per drawn count: 0 asked 8/8, 1 asked 8/8, 2 asked 8/8, 3 asked 7/8\n")
3903                && shown.contains("\n  pooled: played per drawn count: 0 asked 16/16, 1 asked 16/16, 2 asked 16/16, 3 asked 15/16\n")
3904                && shown.contains("\n  intention to treat: ")
3905                && shown.contains("\n  grade 2 asked − handed over: team ")
3906                && shown.contains("; pooled "),
3907            "{shown}"
3908        );
3909        // Asking helps in one collective and hurts in the other: the pool is no reading, per collective only.
3910        let reversed = elsewhere(tested_log(16, &REVERSED, 8));
3911        let p = pooled(&[("team".into(), text(&team)), ("open".into(), text(&reversed))]);
3912        assert!(p.differ.iter().any(|d| d.starts_with("grade 2 asked − handed over: team +") && d.ends_with("read per collective only")), "{:?}", p.differ);
3913        // A drawn check copied from another store's log (its summary key no line boundary here), in a
3914        // session of its own: left out of the counts, the due rule and the share played.
3915        let mut copied = open.clone();
3916        let mut g = open.iter().find(|o| o.kind == outcome::GRADED && o.xs.is_some()).unwrap().clone();
3917        (g.session, g.check, g.seen) = ("copied".into(), "c0000000copy".into(), format!("{}-0badc0de", text(&open).len() + 7));
3918        copied.push(g);
3919        let (with, without) = (pooled(&[("open".into(), text(&copied))]), pooled(&[("open".into(), text(&open))]));
3920        assert_eq!((with.checks, with.sessions, with.strata[0].imported), (without.checks, without.sessions, 1), "{with:?}");
3921        assert_eq!(with.played, without.played);
3922        assert!(render_pooled(&with).contains("; 1 graded records imported from another store's log, left out; "), "{}", render_pooled(&with));
3923        // Not due: counts only.
3924        let p = pooled(&[("team".into(), text(&tested_log(5, &UNDILUTED, 7)))]);
3925        assert!(!p.due && p.ask_free.is_none() && p.by_asked.is_empty() && p.strata[0].readout.ask_free.is_none(), "{p:?}");
3926        assert!(!render_pooled(&p).contains("intention to treat"));
3927    }
3928
3929    /// Asking a 2 against handing it over is a Horvitz–Thompson difference over every 2 that could go
3930    /// either way: a verdict the judge gives the asked more often than the handed over is weighed by
3931    /// its chance (select/policy.rs `judged`), so equal change rates read as no gain; a hand-over never
3932    /// delivered changed nothing, so it counts as 0, not out.
3933    #[test]
3934    fn asking_against_handing_over_weighs_verdicts_by_their_chance_and_counts_what_was_never_delivered() {
3935        let sessions = 1000;
3936        // Both routes change the work every time; the asked are judged 9 times in 10, the free 4.
3937        let judging = Plant { change: |j, _, _| if j == 2 { 0.0 } else { 1.0 }, delivered: 1.0, seen: |ask| if ask { 0.9 } else { 0.4 } };
3938        let even = readout_of(&tested_log(sessions, &judging, 11));
3939        let gain = even.ask_free.as_ref().unwrap();
3940        assert_eq!(gain.n, 4 * sessions, "every 2, judged or not: {gain:?}");
3941        assert!(gain.value.is_some_and(|v| v.abs() < 0.1) && gain.lo < 0.0 && gain.hi > 0.0, "equal rates, no gain: {gain:?}");
3942        // Every delivered lesson changes the work and is judged; half the hand-overs are never
3943        // delivered, so handing over changes the work half the time: a gain of 0.5.
3944        let dropped = Plant { change: |j, _, _| if j == 2 { 0.0 } else { 1.0 }, delivered: 0.5, seen: |_| 1.0 };
3945        let gain = readout_of(&tested_log(sessions, &dropped, 13)).ask_free.unwrap();
3946        assert!(gain.value.is_some_and(|v| (v - 0.5).abs() < 0.08), "{gain:?}");
3947    }
3948
3949    /// Dilution compares the same lesson across the uniform draws: with none planted, a 1 asked
3950    /// beside the 2s does not read as dilution; planted, the top lesson's falling rate is found.
3951    #[test]
3952    fn dilution_compares_the_same_lesson_across_draws_and_finds_only_what_is_planted() {
3953        let sessions = 600;
3954        let none = readout_of(&tested_log(sessions, &UNDILUTED, 17));
3955        let rows: Vec<u16> = none.by_asked.iter().map(|x| x.0).collect();
3956        assert_eq!(rows, [2, 3], "{none:?}");
3957        for (m, e) in &none.by_asked {
3958            assert!(e.value.is_some_and(|v| v.abs() < 0.15) && e.lo < 0.0 && e.hi > 0.0, "{m} asked, no dilution planted: {e:?}");
3959        }
3960        // The top lesson asked changes the work 0.9 of the time alone, 0.6 with 2 asked, 0.3 with 3.
3961        let diluted = Plant {
3962            change: |j, s, ask| match (j, ask) {
3963                (0, true) => [0.0, 0.9, 0.6, 0.3][s],
3964                (2, _) => 0.0,
3965                (_, true) => 0.8,
3966                _ => 0.2,
3967            },
3968            delivered: 1.0,
3969            seen: |_| 1.0,
3970        };
3971        let r = readout_of(&tested_log(sessions, &diluted, 19));
3972        for (m, want) in [(2u16, -0.3), (3, -0.6)] {
3973            let e = &r.by_asked.iter().find(|x| x.0 == m).unwrap().1;
3974            assert!(e.value.is_some_and(|v| (v - want).abs() < 0.15) && e.hi < 0.0, "{m} asked, {want} planted: {e:?}");
3975        }
3976    }
3977
3978    /// The cold-start replay decides as the hooks would: with the fit `borg learn` makes when a session
3979    /// ends, the gate's ledger over the policy's own checks. Until the first end it is the fixed rule;
3980    /// after, the learned policy leaves it within the gate's budget. Without the refits it has no model,
3981    /// never leaves the fixed rule, and P3 passes on nothing.
3982    #[test]
3983    fn the_cold_start_replay_refits_at_each_session_end_and_deviates_within_the_gate() {
3984        let (sessions, checks) = (8, 16);
3985        let log = labelled(sessions, checks, 3);
3986        let (p3, targets) = cold_start(&text(&log), Path::new("/nonexistent/lessons.jsonl"), None);
3987        assert_eq!(p3.refits, sessions - 1, "before the first check after each end but the last: {p3:?}");
3988        assert_eq!((p3.verdict, p3.over, p3.cold, p3.checks), (Verdict::Pass, 0, true, sessions * checks), "{p3:?}");
3989        assert!(p3.deviations > 0, "the learned policy decides: {p3:?}");
3990        assert_ne!(p3.policy, p3.fixed, "its own spend: {p3:?}");
3991        assert_eq!(p3.sources.values().sum::<usize>(), p3.checks, "every decision by its source: {:?}", p3.sources);
3992        assert!(p3.sources.get(policy::FIXED).is_some_and(|n| *n >= checks), "the first session's at least: {:?}", p3.sources);
3993        // The first session is decided before any fit: the fixed rule, its choice as the propensities.
3994        let pass = as_logged(&text(&log), Path::new("/nonexistent/lessons.jsonl"));
3995        for (c, t) in pass.checks.iter().zip(&targets).take(checks) {
3996            assert_eq!(t, &(c.p.clone(), c.pf.clone()), "{}", c.line());
3997        }
3998        assert!(pass.checks.iter().zip(&targets).skip(checks).any(|(c, t)| t != &(c.p.clone(), c.pf.clone())), "then its own propensities");
3999    }
4000
4001    /// P2 runs on the value model (select/policy.rs `fit`, `predict`), leave-future-out: the summary it
4002    /// fits on holds exactly the labels whose result came before, no check after them, and while there
4003    /// is none the prior predicts.
4004    #[test]
4005    fn calibration_fits_the_value_model_on_the_labels_before_each_check() {
4006        let log = labelled(6, 12, 5);
4007        let none = Path::new("/nonexistent/lessons.jsonl");
4008        let pass = as_logged(&text(&log), none);
4009        let (ls, unjoined) = labels(&pass.checks, &log);
4010        assert!(ls.len() > 20 && unjoined == 0, "{} labels, {unjoined} unjoined", ls.len());
4011        let p2 = calibration(&ls, unjoined, &|value, past, at| model(&pass.checks, past, at, value));
4012        assert!(matches!(p2.verdict, Verdict::Pass | Verdict::Fail), "{p2:?}");
4013        assert!(p2.model.is_some_and(f64::is_finite) && p2.diff.as_ref().is_some_and(|d| d.n > 0) && p2.informed > 0, "{p2:?}");
4014        let cut = ls[ls.len() / 2].check_ms;
4015        let past: Vec<&Label> = ls.iter().filter(|l| l.first() < cut).collect();
4016        let s = known(&pass.checks, &past, cut);
4017        let judged = s.applies.iter().filter(|a| matches!(a.label, summary::Label::Changed | summary::Label::Ignored)).count();
4018        assert_eq!(judged, past.len(), "exactly the labels before");
4019        let newest = past.iter().filter_map(|l| l.at(cut)).map(|k| k.0).max().unwrap();
4020        assert!(s.checks.iter().all(|c| c.ms <= newest), "no check after them");
4021        assert!(policy::fit(&s, None).model.is_some());
4022        let prior = model(&pass.checks, &[], cut, Value::Full).unwrap();
4023        assert!(ls.iter().all(|l| (0.0..1.0).contains(&prior(l))), "the prior predicts before any label");
4024        assert_eq!(known(&pass.checks, &[], cut), Summary::default());
4025        // The pruned rerun predicts before any label by its own prior (select/policy.rs `prior_pruned`).
4026        let pruned = model(&pass.checks, &[], cut, Value::Pruned).unwrap();
4027        let by = |fit: &Fit, l: &Label| {
4028            let o = &pass.checks[l.check].o;
4029            let units: Vec<Unit> = o
4030                .lessons
4031                .iter()
4032                .zip(&o.grades)
4033                .enumerate()
4034                .map(|(j, (lesson, grade))| Unit { lesson, grade: *grade, rank: o.ranks.get(j).map_or(j, |r| usize::from(*r)) })
4035                .collect();
4036            let route = [Route::Ask, Route::Free, Route::Not][l.route];
4037            let m = routes_of(&pass.checks[l.check]).iter().filter(|r| **r == route).count();
4038            policy::predict(fit, &policy::Feat::of(&units, o.channel == "stop", o.tokens)[l.j], route, m, &o.session, &o.lessons[l.j]).unwrap()
4039        };
4040        assert!(ls.iter().all(|l| pruned(l) == by(&policy::prior_pruned(), l)), "the pruned prior");
4041        // Every prior mean but not now's is 0 (select/policy.rs `NOT_PRIOR`), so an asked or free unit is
4042        // ½ under either prior: the two part where a unit was not delivered.
4043        let not: Vec<Label> = ls.iter().map(|l| Label { route: NOT, ..l.clone() }).collect();
4044        assert!(not.iter().all(|l| pruned(l) == by(&policy::prior_pruned(), l)), "the pruned prior, not delivered");
4045        assert!(not.iter().any(|l| pruned(l) != by(&policy::prior(), l)), "not the full model's prior");
4046    }
4047
4048    /// An evaluation's run directory: per run `<case>-s<seed>/borg-state/<session>.ctx` with its graded
4049    /// lines, and the qrels beside them.
4050    fn eval_dir(name: &str, runs: &[(&str, &str)], qrels: &[(&str, &str)]) -> PathBuf {
4051        let d = std::env::temp_dir().join(format!("borg-replay-{name}-{}", std::process::id()));
4052        let _ = std::fs::remove_dir_all(&d);
4053        for (i, (case, ctx)) in runs.iter().enumerate() {
4054            let state = d.join("runs").join(format!("{case}-s{i}")).join("borg-state");
4055            std::fs::create_dir_all(&state).unwrap();
4056            std::fs::write(state.join(format!("sess{i}.ctx")), ctx).unwrap();
4057        }
4058        let q: String = qrels.iter().map(|(case, id)| format!("{}\n", serde_json::json!({"case": case, "challenge": id, "grade": 2}))).collect();
4059        std::fs::write(d.join("qrels.jsonl"), q).unwrap();
4060        d
4061    }
4062
4063    /// P4's arms are the production functions' (rules v2): P4a select/policy.rs `decide_on_evidence`
4064    /// under the n = 0 prior, P4b the same under the whole log's fit (what `borg learn` would fit, not
4065    /// the folded summary's prior), P4c `choose` under the prior with an open gate, whose draws leave the
4066    /// fixed rule on a live check with lessons it may hand over or ask. A posterior arm on the prior, or
4067    /// a test arm on no live check, would pass on nothing.
4068    #[test]
4069    fn p4_draws_its_arms_from_the_production_rule_the_posterior_and_the_test() {
4070        let log = labelled(6, 12, 7);
4071        // A stop check, and a live one (its question delivered live) with two 2s and a 1 the test may move.
4072        let checks = "#borg-graded n=4 R:2:0,X:1:1,Y:1:2,Z:2:3\n#borg-graded n=3 W:3:0,R:1:1,V:2:2\n#borg-live W ms=5\n";
4073        let d = eval_dir("p4", &[("c1", checks), ("c1", checks)], &[("c1", "R")]);
4074        let (runs, qrels) = (d.join("runs"), d.join("qrels.jsonl"));
4075        let none = Path::new("/nonexistent/lessons.jsonl");
4076        let report = evaluate(Input { text: &text(&log), lessons: none, summary: Summary::default(), replay: true, eval: Some((&runs, &qrels)) });
4077        let p4 = report.checks.map(|c| c.p4).unwrap();
4078        let whole = policy::fit(&summary::fold(&text(&log), Summary::default()), None);
4079        assert!(whole.model.is_some() && whole.labels > 0);
4080        assert_eq!((&p4.fit, p4.labels), (&whole.key, whole.labels), "the store's posterior: {p4:?}");
4081        assert_eq!((p4.runs, p4.checks, p4.applying), (2, 4, 4));
4082        // Each arm is its production function's, drawn as `recalls` draws.
4083        let (runs, right) = (eval_runs(&runs), super::qrels(&qrels));
4084        assert_eq!(runs[0].checks.iter().map(|c| c.channel).collect::<Vec<_>>(), ["stop", "live"]);
4085        let prior = policy::prior();
4086        // Each at `P_DRAWS` draws: the decision rule's one draw is its expectation only if it draws nothing.
4087        let arm = |draw: &Draw| {
4088            let r = recalls(&runs, &right, Some(draw), conscience::P_DRAWS, none);
4089            (r.deviations, r.first, r.spend)
4090        };
4091        let of = |p: &Option<Part>| p.as_ref().map(|p| (p.arm.deviations, p.arm.first.clone(), [p.arm.questions, p.arm.free, p.arm.blocks])).unwrap();
4092        let a = |u: &[Unit], c: &EvalCheck, _: u64| policy::decide_on_evidence(u, &eval_check(c), &prior, &[]);
4093        let b = |u: &[Unit], c: &EvalCheck, _: u64| policy::decide_on_evidence(u, &eval_check(c), &whole, &[]);
4094        let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: OPEN, ..summary::Gate::default() };
4095        let t = |u: &[Unit], c: &EvalCheck, s: u64| policy::choose(u, &eval_check(c), &prior, &gate, policy::ALPHA, s, &[]).routes;
4096        assert_eq!(of(&p4.p4a), arm(&a), "P4a");
4097        assert_eq!(of(&p4.p4b), arm(&b), "P4b");
4098        assert_eq!(of(&p4.p4c), arm(&t), "P4c");
4099        assert!(p4.p4c.as_ref().unwrap().arm.deviations > 0, "the test's draws leave the fixed rule on the live check: {p4:?}");
4100        let _ = std::fs::remove_dir_all(d);
4101    }
4102}