Skip to main content

borg/select/
summary.rs

1//! What a hook needs to decide, folded from the outcome log once and cached, so no check parses the
2//! whole log: every graded apply with its route and what became of it, the delivery tallies, the
3//! pool's yield and latency, the gate's ledger, the monitor, the checks whose learned decision fell
4//! back to the fixed rule, the randomised question-count test's checks, and the fit
5//! (select/policy.rs).
6//!
7//! **The cache** `<store>/select-summary.json` sits beside the lessons file, local and never synced.
8//! `load` reads it and folds only the log's new tail (from `at`); a missing, corrupt or older cache, or
9//! a log shorter than `at` (rewritten), is rebuilt from the whole log in memory; `peek` (an early or
10//! `PostToolUse` hook) never rebuilds: the prior instead. Neither writes: `refresh` (the live check and
11//! the stop) loads and saves what it folded, and `refit` (`borg learn`) fits and saves. Any error is
12//! the prior, never a panic in a hook.
13//!
14//! **The fold** is pure over an append-only log: `fold` of a prefix, then of the rest, equals `fold` of
15//! the whole at every line boundary, and only whole lines fold (a line being written waits). A result
16//! joins the check that decided it by check id (outcome.rs `check`); a record from before check ids by
17//! the session's last graded check before it that graded the lesson on the route its channel names, as
18//! attention.rs `labels` joins. A later verdict for the same check and lesson supersedes an earlier one
19//! and a censored mark, so nothing counts twice. Kinds it does not use skip serde: the kind is read off
20//! the line's head first (12,577 `regretted` lines from one session cost a substring each).
21//!
22//! **Session ticks**: a session's tick is its place in the order sessions first appear in the folded
23//! log (memory.rs `Clock`'s idea, in line order so the fold stays incremental); ages are in ticks,
24//! never in wall time.
25//!
26//! **Session ends**: a session's delivery tallies (`Session::tally`) count at its end, whatever it
27//! logged: its end hook's `ended` record (outcome.rs `ENDED`) or the `never_delivered` deliveries
28//! before it, or a settle (`asked_not_corrected`, which `borg learn` also writes for a session idle
29//! without its end hook). A delivery recorded after that (a resumed session) moves its apply's count,
30//! so the tallies follow the latest record.
31//!
32//! **What a session had**: a lesson shown to a session (asked, handed over free or delivered early; a
33//! placebo is judged, never shown) is in its `Session::had` from that delivery on in line order, or in
34//! `Summary::fresh` until a first graded check or settle gives the session a tick. An apply of a lesson
35//! its session had before its check (`Apply::had`: the record's `nofree`, the hook's view, or that set,
36//! the log's) counts in no delivery tally, whose rates are those of a lesson the session lacks
37//! (select/policy.rs `Rates`): delivery drops a free one as given before, and a question once its lesson
38//! is done (hook.rs `done_in`: asked, or handed over and acted on, as the drone may before the question
39//! reaches it), drops that say nothing of whether a lesson the session lacks arrives. A lesson waiting
40//! in the session's queue from another check is in the record's `nofree` too, which delivery would drop
41//! as queued twice. A question of one handed over and not acted on is delivered and judged as any
42//! other: its verdict counts (top.rs label health), its delivery stays out of the tallies with the
43//! drops. Before 0.6.0 such an apply of a free lesson counted as undelivered and a question as
44//! delivered, so the learned free rate read low against asking.
45//!
46//! **The tail**: a graded record's `tail` holds lessons the pool's budget dropped that a uniform sample
47//! showed the model anyway, each with the chance `pt`, and its `fill` holds, after the applies'
48//! positions, each tail lesson's position: `shown` plus its place among the dropped lessons in the fill
49//! order (whole.rs `select_filled`), where a budget that held them would have put it (`tail_of`). Each
50//! counts in the pool's yield bins at its position as a shown lesson, applied when it is one of the
51//! applies, so the yield is learned past the budget. Unweighted: a uniform sample's lesson at a position
52//! is one draw of that position's yield, and a 1/pt weight would count it as 1/pt lessons, overstating
53//! the yield's certainty. A bin the budget splits holds all its positions below the budget and a pt
54//! share of those above, so its yield leans to its lower positions.
55//!
56//! **Compaction** bounds the cache: past `MAX_APPLIES` the oldest checks go, past `MAX_SESSIONS` the
57//! oldest sessions with their checks, and with either the sessions before the first kept check's, but
58//! never one of the newest `policy::FIT_SESSIONS` (the sessions a fit reads). What the dropped
59//! sessions' readers need stays in `Past`, and the kept ones' ticks shift down by as many, so every
60//! age stays as it was. A session that logs again after its drop starts a new one.
61//!
62//! **The question-count test** (select/policy.rs `choose`, rules v2): a graded record of the learned
63//! policy that logs k (outcome.rs `xk`) was considered by the test, and one that also logs s (`xs`)
64//! drew its count: played (source `EXPERIMENT`, or `EXPERIMENT_CONTROL` when s is the fixed rule's own
65//! count), or refused by the gate (`GATE`), which then plays the fixed rule. Every drawn check is one
66//! of the test's randomised checks: its routes, as played, and its propensities, over the k + 1 equally
67//! likely counts with the refused ones' routes the fixed rule's, are what the draw made them, so the
68//! readout reads refused draws too. The fold counts them all-time by k and s (`Summary::experiment`,
69//! which compaction keeps) and marks a drawn one's check (`Check::xk`, `xs`), whose applies hold the
70//! verdicts of what it asked and handed over: what the readout reads (select/replay.rs `test_readout`),
71//! never a recount of the log. A record whose k or s cannot be its own (no s, s past k, k past its
72//! applies) counts nowhere. Like every learned check, a drawn one is on the gate's ledger.
73
74use std::collections::{BTreeMap, HashMap};
75use std::io::{Read, Seek};
76use std::path::{Path, PathBuf};
77
78use serde::{Deserialize, Serialize};
79
80use super::Route;
81use super::policy::{self, Fit, Monitor};
82use crate::outcome::{self, Outcome};
83
84/// The cache's format. Protocol: a summary of another version is rebuilt, never read (2: `spend`;
85/// 3: `Pool::stops`, the monitor's weights; 4: `Past`, `LATER` a live check's, tallies at `ENDED`;
86/// 5: `ASK_LIVE`, what a session had (`Session::had`, `Apply::had`, `Apply::nofree`, `Summary::fresh`),
87/// tail yields and counts; 6: `Summary::fell_back`; 7: the question-count test, `Summary::experiment`,
88/// `Check::xk`, `xs`, `Session::tested`; 8: a stop's results on a live check's hand-overs it asked
89/// (`stop_routes`), a live question of a check without a 3 out of `ASK_LIVE` and `WAIT`, `counted`;
90/// 9: the judge's `not_arisen` and `consequence_remains` verdicts, `Label::NotArisen`, `Label::Remains`,
91/// `Apply::jv`, the session-end judge's own judge level; 10: `Delivered::Superseded`, out of `counted`).
92pub const SV: u32 = 10;
93/// The cache beside the lessons file. Protocol.
94pub const CACHE: &str = "select-summary.json";
95/// The monitor's auto-revert mark beside the lessons file: while it exists the store keeps the fixed
96/// rule, whatever the cache holds (sticky: removing it is the engineer's call, and resumes learning
97/// until the monitor trips again). Protocol.
98pub const REVERTED: &str = "select-reverted";
99/// Graded applies a summary keeps at most. Guard: bounds the cache a hook reads; past it the oldest
100/// checks holding a quarter of them go (`Summary::dropped` counts their applies), whose labels the
101/// forgetting already weighs least.
102pub const MAX_APPLIES: usize = 50_000;
103/// Sessions a summary keeps at most. Guard: bounds the session table, which `MAX_APPLIES` does not
104/// when sessions grade few applies (a session is about 100 bytes of cache, so 4,096 are 0.4 MB against
105/// the applies' 5 MB); past it the oldest quarter go, with their checks, into `Past`.
106pub const MAX_SESSIONS: usize = 4_096;
107// A compaction keeps three quarters of `MAX_SESSIONS`: never fewer sessions than a fit reads.
108const _: () = assert!(MAX_SESSIONS - MAX_SESSIONS / 4 >= policy::FIT_SESSIONS);
109/// Sessions not in the table yet whose deliveries a summary keeps (`Summary::fresh`). Guard: bounds the
110/// cache against sessions shown lessons (an early delivery at a prompt) that never reach a graded check,
111/// which no other bound counts; far more than run at once, about 0.1 MB. Past it the oldest quarter go,
112/// and one of them graded after all counts its applies of lessons it had as a session without them.
113pub const MAX_FRESH: usize = 1_024;
114/// Model calls whose latency the summary keeps, the newest: of all, and of each harness's stops
115/// (`Pool::stops`). Guard: bounds the cache, which a long-lived store's calls would grow without end.
116pub const LATENCY_KEEP: usize = 2_000;
117
118/// What delivery did with a decided apply (outcome.rs `delivery`, or an older `asked` record).
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum Delivered {
122    /// Nothing recorded yet (and, at its session's end, not delivered).
123    #[default]
124    Unknown,
125    Yes,
126    /// Dropped at delivery (the session had it, the budget was spent) or never delivered.
127    Dropped,
128    /// A detached check's waiting ask a newer check asked again (outcome.rs `ROUTE_SUPERSEDED`): not
129    /// delivered, and no undelivered question either (`counted`); the newer ask's delivery counts.
130    Superseded,
131}
132
133impl Delivered {
134    /// Never reached the drone: dropped, or superseded by a newer ask.
135    #[must_use]
136    pub fn never(self) -> bool {
137        matches!(self, Delivered::Dropped | Delivered::Superseded)
138    }
139}
140
141/// What the judge made of an apply.
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum Label {
145    /// No verdict yet.
146    #[default]
147    Open,
148    /// Changed the work and avoids the consequence (`asked_then_changed`).
149    Changed,
150    /// Changed nothing on its point (`asked_ignored`).
151    Ignored,
152    /// The judge could not tell (`asked_unclear`; before 0.7.5 also a change that left the consequence).
153    Unclear,
154    /// Its session settled without a verdict (`asked_not_corrected` with an unjudged reason).
155    Censored,
156    /// Its point never came up in the work (`asked_not_arisen`, 0.7.5 on): a verdict, read as "nothing in
157    /// the work", no change on the lesson's point (select/policy.rs `Data::of`, `judged`; replay.rs
158    /// `unit_outcome`). Appended, so older caches keep their labels' places.
159    #[serde(rename = "not_arisen")]
160    NotArisen,
161    /// Changed on its point but the consequence remains (`asked_consequence_remains`, 0.7.5 on): read as
162    /// an unclear verdict everywhere but the label health (top.rs), which counts it apart.
163    Remains,
164}
165
166impl Label {
167    /// Does a verdict of kind `next` replace this one? A verdict (changed, ignored, not arisen) replaces
168    /// anything; a pending one (unclear, remains) an open or censored mark or another pending one, so
169    /// the latest pending verdict shows; a censored mark only an open one.
170    fn yields_to(self, next: Label) -> bool {
171        match next {
172            Label::Changed | Label::Ignored | Label::NotArisen => true,
173            Label::Unclear | Label::Remains => matches!(self, Label::Open | Label::Censored | Label::Unclear | Label::Remains),
174            Label::Censored => self == Label::Open,
175            Label::Open => false,
176        }
177    }
178
179    /// Whether the judge left it pending (`Unclear`, `Remains`): no verdict for the fit, the readout or
180    /// the judge-missingness model, as an unclear one has always been.
181    #[must_use]
182    pub fn pending(self) -> bool {
183        matches!(self, Label::Unclear | Label::Remains)
184    }
185
186    /// Whether it is a verdict that settles its apply (`Changed`, `Ignored`, `NotArisen`).
187    #[must_use]
188    pub fn verdict(self) -> bool {
189        matches!(self, Label::Changed | Label::Ignored | Label::NotArisen)
190    }
191}
192
193fn is_default<T: Default + PartialEq>(v: &T) -> bool {
194    *v == T::default()
195}
196
197/// One graded apply: its lesson, grade and rank, the route its check decided with its propensities
198/// (logged `p`, `pf`; `psi` a placebo's chance of the draw), and what came of it.
199#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
200pub struct Apply {
201    /// Its check (`Summary::checks`) and lesson (`Summary::lessons`).
202    pub check: u32,
203    pub lesson: u32,
204    pub grade: u8,
205    #[serde(default, skip_serializing_if = "is_default")]
206    pub rank: u16,
207    pub route: Route,
208    #[serde(default, skip_serializing_if = "is_default")]
209    pub p: f64,
210    #[serde(default, skip_serializing_if = "is_default")]
211    pub pf: f64,
212    #[serde(default, skip_serializing_if = "is_default")]
213    pub psi: f64,
214    #[serde(default, skip_serializing_if = "is_default")]
215    pub delivered: Delivered,
216    #[serde(default, skip_serializing_if = "is_default")]
217    pub label: Label,
218    /// The judge of its verdict (`Summary::judges`: template version, model and prompt).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub judge: Option<u32>,
221    /// The judge template version (outcome.rs `judge`) of the answer its label shows, a pending one
222    /// (`Label::pending`) too; 0 none. Read by the label health only (top.rs: the verdict shares from
223    /// `predict::PURE_UNCLEAR_FROM` on), never by the fit, whose judge levels are `judge`'s.
224    #[serde(default, skip_serializing_if = "is_default")]
225    pub jv: u8,
226    /// The judge saw it without the drone's declaration.
227    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
228    pub stripped: bool,
229    /// Left for later, then delivered later in the session.
230    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
231    pub later: bool,
232    /// A live question still waiting at the stop, asked there.
233    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
234    pub waited: bool,
235    /// Its lesson was shown to its session before its check, or was waiting in its queue from another
236    /// check (the record's `nofree`; module doc: what a session had): in no delivery tally (`counted`),
237    /// though a question of it may still be delivered and judged.
238    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
239    pub had: bool,
240    /// Its check could not hand it over free: in the record's `nofree`, the `had` its decision read
241    /// (select.rs `Ctx::had`), so the gate's ledger and the monitor value the check against the fixed
242    /// rule it was decided against (select/policy.rs `ledger`, `monitor`). Never the log's view, which
243    /// `had` adds and the decision may not have seen.
244    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
245    pub nofree: bool,
246    /// The session's delivered applies still waiting for a verdict when its check ran.
247    #[serde(default, skip_serializing_if = "is_default")]
248    pub pending: u16,
249}
250
251/// One graded check with applies.
252#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
253pub struct Check {
254    /// select.rs `check_id`; empty for a record from before check ids.
255    #[serde(default, skip_serializing_if = "String::is_empty")]
256    pub id: String,
257    /// Its session's tick (`Summary::sessions`).
258    pub tick: u32,
259    pub ms: i64,
260    /// The stop's (else live).
261    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
262    pub stop: bool,
263    /// Decided under the learned policy (outcome.rs `policy` "learned"): on the gate's ledger.
264    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
265    pub learned: bool,
266    /// Its pool's tokens (0 unknown) and its model and prompt (`Summary::models`).
267    #[serde(default, skip_serializing_if = "is_default")]
268    pub tokens: u32,
269    #[serde(default, skip_serializing_if = "is_default")]
270    pub model: u32,
271    /// Its applies: `Summary::applies[first..first + n]`; how many it asked and handed over.
272    pub first: u32,
273    pub n: u16,
274    #[serde(default, skip_serializing_if = "is_default")]
275    pub m_q: u16,
276    #[serde(default, skip_serializing_if = "is_default")]
277    pub m_f: u16,
278    /// Counted into its session's delivery tallies (at the session's end, module doc).
279    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
280    pub tallied: bool,
281    /// A check whose question-count test drew its count (module doc), played or refused: k, the fixed
282    /// rule's delivered non-3 lessons that may go free, and s, the drawn number of them to ask
283    /// (outcome.rs `xk`, `xs`). None on every other check.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub xk: Option<u32>,
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub xs: Option<u32>,
288}
289
290/// The delivery tallies of a session, (applies, delivered), counted when it ends (`counted`): a stop's
291/// free lessons, a live check's, a live check's lessons left for later, and of a live check with a 3
292/// its questions waiting for the stop and its questions delivered. Protocol: the indices of `Session::tally`, part of the cache's layout
293/// (`SV`).
294pub const FREE_STOP: usize = 0;
295pub const FREE_LIVE: usize = 1;
296pub const LATER: usize = 2;
297pub const WAIT: usize = 3;
298pub const ASK_LIVE: usize = 4;
299/// How many delivery tallies a session keeps, `FREE_STOP` to `ASK_LIVE`. Protocol: the width of
300/// `Session::tally` and `Past::tally`, part of the cache's layout (`SV`).
301pub const TALLIES: usize = 5;
302const _: () = assert!(ASK_LIVE + 1 == TALLIES);
303
304/// One session, in tick order.
305#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
306pub struct Session {
307    pub id: String,
308    /// Ended (its first end record, module doc).
309    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
310    pub ended: bool,
311    /// Its graded checks, applies or not: what its pools cost (select/policy.rs `price`).
312    #[serde(default, skip_serializing_if = "is_default")]
313    pub checks: u32,
314    /// `FREE_STOP` to `ASK_LIVE`: (applies, delivered).
315    #[serde(default, skip_serializing_if = "is_default")]
316    pub tally: [(u32, u32); TALLIES],
317    /// The lessons shown to it so far (`Summary::lessons`), sorted (module doc: what a session had).
318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
319    pub had: Vec<u32>,
320    /// A check of it drew the question-count test's count: counted once in `Experiment::sessions`.
321    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
322    pub tested: bool,
323}
324
325/// What a session not in the table yet was shown (`Summary::fresh`), until its first graded check or
326/// settle makes it its `Session::had`; `seq`, the log offset after its first delivery, orders the
327/// guard's drops (`MAX_FRESH`).
328#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
329pub struct Fresh {
330    pub seq: u64,
331    pub had: Vec<u32>,
332}
333
334/// The pool's evidence: per log2 bin of fill position (positions 2^b − 1 to 2^(b+1) − 2), lessons
335/// shown and applied; tokens and lessons shown in all; the model calls' (tokens, ms), the pool's tokens
336/// (`tokens`, what select/policy.rs `pool_feasible` predicts a budget at; the draws past it, outcome.rs
337/// `xtokens`, stay out, so the fit and its prediction read one size); and per
338/// harness, its stops' model calls (ms, failed), what borg top shows against the harness's stop
339/// budget (top.rs `Latency`).
340#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
341pub struct Pool {
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub bins: Vec<(u64, u64)>,
344    #[serde(default, skip_serializing_if = "is_default")]
345    pub tokens: u64,
346    #[serde(default, skip_serializing_if = "is_default")]
347    pub shown: u64,
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub latency: Vec<(u32, u32)>,
350    /// Keyed by a harness borg knows (harness/mod.rs `ALL`), the newest `LATENCY_KEEP` of each.
351    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
352    pub stops: BTreeMap<String, Vec<(u32, bool)>>,
353    /// The tail samples' lessons, shown past the pool, and those applied (`tail_of`), as borg top shows
354    /// them; also in `bins`.
355    #[serde(default, skip_serializing_if = "is_default")]
356    pub tail: (u64, u64),
357}
358
359/// Early deliveries the judge gave a verdict, and those that changed the work: shown; a learned early
360/// rule waits for each delivery's relative score in the log (conscience.rs `RELATIVE`).
361#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
362pub struct Early {
363    #[serde(default, skip_serializing_if = "is_default")]
364    pub judged: u32,
365    #[serde(default, skip_serializing_if = "is_default")]
366    pub changed: u32,
367}
368
369/// What the checks' model calls spent, per channel (`STOP`, `LIVE`): calls, the extra rounds a stop's
370/// block cost its session, and dollars as the CLIs reported them (outcome.rs `usd`; 0 before 0.6.0).
371/// Measured and shown, never a limit.
372#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
373pub struct Spent {
374    #[serde(default, skip_serializing_if = "is_default")]
375    pub calls: u64,
376    #[serde(default, skip_serializing_if = "is_default")]
377    pub rounds: u64,
378    #[serde(default, skip_serializing_if = "is_default")]
379    pub usd: f64,
380}
381
382/// `Summary::spend`'s channels. Protocol: its indices, part of the cache's layout (`SV`).
383pub const STOP: usize = 0;
384pub const LIVE: usize = 1;
385
386/// Why a graded check's learned decision fell back to the fixed rule, as its record's `reason` names it
387/// (outcome.rs `DECIDED_BUSY`, `DECIDED_NO_LOCK`, `DECIDED_NO_RECORD`). Protocol: the indices of
388/// `Summary::fell_back`, part of the cache's layout (`SV`).
389pub const FELL_BACK: [&str; 3] = [outcome::DECIDED_BUSY, outcome::DECIDED_NO_LOCK, outcome::DECIDED_NO_RECORD];
390
391/// The randomised question-count test's checks (module doc), all-time: per (k, s) the checks the test
392/// ran, each having drawn count s of k, played or refused, sorted (`ran`); of them the control arm's (`EXPERIMENT_CONTROL`: s the
393/// fixed rule's own count) and, per k, those the gate refused (`GATE`), sorted, each also in `ran`;
394/// the sessions with a drawn check. What `borg top` and the readout count
395/// (select/replay.rs `test_readout`), never a recount of the log.
396#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
397pub struct Experiment {
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub ran: Vec<(u32, u32, u64)>,
400    #[serde(default, skip_serializing_if = "is_default")]
401    pub control: u64,
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub refused: Vec<(u32, u64)>,
404    #[serde(default, skip_serializing_if = "is_default")]
405    pub sessions: u32,
406}
407
408impl Experiment {
409    /// Checks that drew a count, and those the gate refused.
410    #[must_use]
411    pub fn totals(&self) -> (u64, u64) {
412        (self.ran.iter().map(|c| c.2).sum(), self.refused.iter().map(|c| c.1).sum())
413    }
414}
415
416/// Signals beside the judge, counted before (`Summary::signals[0]`) and after (`[1]`) the first check
417/// of the learned policy: sessions, corrections, vetoes, regrets, green test runs.
418#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
419pub struct Signals {
420    #[serde(default, skip_serializing_if = "is_default")]
421    pub sessions: u32,
422    #[serde(default, skip_serializing_if = "is_default")]
423    pub corrections: u32,
424    #[serde(default, skip_serializing_if = "is_default")]
425    pub vetoes: u32,
426    #[serde(default, skip_serializing_if = "is_default")]
427    pub regrets: u32,
428    #[serde(default, skip_serializing_if = "is_default")]
429    pub tested: u32,
430}
431
432/// The conservative gate's ledger over the learned policy's checks under the current fit
433/// (select/policy.rs `ledger_add_without`): per joint draw, the replayed value of what the policy did
434/// minus the fixed rule's for the lessons its session had (A); α × the fixed rule's replayed value
435/// (B). A deviation passes while A + its own gain stays ≥ −B at the `DELTA` quantile. B is quality
436/// (mistakes avoided), not money.
437#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
438pub struct Gate {
439    #[serde(default, skip_serializing_if = "Vec::is_empty")]
440    pub gain: Vec<f64>,
441    #[serde(default, skip_serializing_if = "is_default")]
442    pub allowance: f64,
443    #[serde(default, skip_serializing_if = "is_default")]
444    pub checks: u32,
445    #[serde(default, skip_serializing_if = "is_default")]
446    pub deviated: u32,
447}
448
449/// What compaction took off the session table (module doc), as the totals its readers need: those of
450/// the dropped sessions with graded checks and their checks (`Summary::checked`), and their delivery
451/// tallies as (delivered, not) per half-life of `policy::HALF_LIVES` (same order), each session's
452/// weighted by its age at the first kept session (`Summary::tallies` ages them on from there).
453#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
454pub struct Past {
455    #[serde(default, skip_serializing_if = "is_default")]
456    pub sessions: u32,
457    #[serde(default, skip_serializing_if = "is_default")]
458    pub checks: u64,
459    #[serde(default, skip_serializing_if = "Vec::is_empty")]
460    pub tally: Vec<[(f64, f64); TALLIES]>,
461}
462
463impl Past {
464    /// `gone`, the oldest sessions, added; what was here already ages by their count.
465    fn add(&mut self, gone: &[Session]) {
466        if gone.is_empty() {
467            return;
468        }
469        let n = gone.len() as u32;
470        self.tally.resize(policy::HALF_LIVES.len(), [(0.0, 0.0); TALLIES]);
471        for (sums, &h) in self.tally.iter_mut().zip(&policy::HALF_LIVES) {
472            let aged = policy::decay(n, h);
473            for e in sums.iter_mut() {
474                *e = (e.0 * aged, e.1 * aged);
475            }
476            for (t, x) in gone.iter().enumerate() {
477                weigh(sums, &x.tally, policy::decay(n - t as u32, h));
478            }
479        }
480        for x in gone.iter().filter(|x| x.checks > 0) {
481            self.sessions += 1;
482            self.checks += u64::from(x.checks);
483        }
484    }
485}
486
487/// A session's `tally`, (applies, delivered), into `sums`, (delivered, not), at weight `w`.
488fn weigh(sums: &mut [(f64, f64); TALLIES], tally: &[(u32, u32); TALLIES], w: f64) {
489    for (s, &(n, k)) in sums.iter_mut().zip(tally) {
490        s.0 += w * f64::from(k);
491        s.1 += w * f64::from(n.saturating_sub(k));
492    }
493}
494
495/// The selection state of one store (module doc).
496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
497pub struct Summary {
498    /// `SV` when written.
499    pub sv: u32,
500    /// The bytes of `outcomes.jsonl` folded so far, and a hash chained over its lines.
501    pub at: u64,
502    /// The value model decisions draw from; the prior without data.
503    #[serde(default)]
504    pub fit: Fit,
505    #[serde(default, skip_serializing_if = "String::is_empty")]
506    pub hash: String,
507    #[serde(default, skip_serializing_if = "Vec::is_empty")]
508    pub sessions: Vec<Session>,
509    /// Sessions shown lessons before they have a tick, by session id (module doc: what a session had).
510    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
511    pub fresh: BTreeMap<String, Fresh>,
512    /// Lesson ids, model-and-prompt keys and judge keys, by index.
513    #[serde(default, skip_serializing_if = "Vec::is_empty")]
514    pub lessons: Vec<String>,
515    #[serde(default, skip_serializing_if = "Vec::is_empty")]
516    pub models: Vec<String>,
517    #[serde(default, skip_serializing_if = "Vec::is_empty")]
518    pub judges: Vec<String>,
519    #[serde(default, skip_serializing_if = "Vec::is_empty")]
520    pub checks: Vec<Check>,
521    #[serde(default, skip_serializing_if = "Vec::is_empty")]
522    pub applies: Vec<Apply>,
523    #[serde(default, skip_serializing_if = "is_default")]
524    pub pool: Pool,
525    #[serde(default, skip_serializing_if = "is_default")]
526    pub early: Early,
527    #[serde(default, skip_serializing_if = "is_default")]
528    pub signals: [Signals; 2],
529    #[serde(default, skip_serializing_if = "is_default")]
530    pub spend: [Spent; 2],
531    /// Graded checks whose learned decision fell back to the fixed rule, per reason (`FELL_BACK`), with
532    /// applies or none: a guard that binds often shows (top.rs `Selection::fallbacks`).
533    #[serde(default, skip_serializing_if = "is_default")]
534    pub fell_back: [u64; FELL_BACK.len()],
535    /// The question-count test's checks (module doc).
536    #[serde(default, skip_serializing_if = "is_default")]
537    pub experiment: Experiment,
538    /// A check of the learned policy was folded: the signals count as after.
539    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
540    pub era: bool,
541    #[serde(default, skip_serializing_if = "is_default")]
542    pub gate: Gate,
543    #[serde(default, skip_serializing_if = "is_default")]
544    pub monitor: Monitor,
545    /// Applies compaction dropped, and what it kept of the sessions it dropped.
546    #[serde(default, skip_serializing_if = "is_default")]
547    pub dropped: u64,
548    #[serde(default, skip_serializing_if = "is_default")]
549    pub past: Past,
550}
551
552impl Default for Summary {
553    /// The prior: nothing folded, every decision the fixed rule's.
554    fn default() -> Self {
555        Summary {
556            sv: SV,
557            at: 0,
558            fit: Fit::default(),
559            hash: String::new(),
560            sessions: Vec::new(),
561            fresh: BTreeMap::new(),
562            lessons: Vec::new(),
563            models: Vec::new(),
564            judges: Vec::new(),
565            checks: Vec::new(),
566            applies: Vec::new(),
567            pool: Pool::default(),
568            early: Early::default(),
569            signals: Default::default(),
570            spend: Default::default(),
571            fell_back: Default::default(),
572            experiment: Experiment::default(),
573            era: false,
574            gate: Gate::default(),
575            monitor: Monitor::default(),
576            dropped: 0,
577            past: Past::default(),
578        }
579    }
580}
581
582impl Summary {
583    /// What a fit on this summary is keyed by: the bytes folded and the line hash's first 8 hex digits
584    /// (protocol: logged with each check, `Log::fit`).
585    #[must_use]
586    pub fn key(&self) -> String {
587        format!("{}-{}", self.at, self.hash.get(..8).unwrap_or(&self.hash))
588    }
589
590    /// Does every index point inside its table, and do the checks tile the applies in order? A cache
591    /// that parses but fails this is corrupt (`read`): the fold indexes by them.
592    fn consistent(&self) -> bool {
593        let mut next = 0u32;
594        let checks = self.checks.iter().all(|c| {
595            let ok = c.first == next && (c.tick as usize) < self.sessions.len() && (c.model as usize) < self.models.len().max(1);
596            next = c.first + u32::from(c.n);
597            ok
598        });
599        let applies = self.applies.iter().enumerate().all(|(i, a)| {
600            let inside = self.checks.get(a.check as usize).is_some_and(|c| (c.first as usize..c.first as usize + usize::from(c.n)).contains(&i));
601            inside && (a.lesson as usize) < self.lessons.len() && a.judge.is_none_or(|j| (j as usize) < self.judges.len())
602        });
603        let gate = self.gate.gain.is_empty() || self.gate.gain.len() == policy::DRAWS;
604        let past = self.past.tally.is_empty() || self.past.tally.len() == policy::HALF_LIVES.len();
605        let had = self.sessions.iter().flat_map(|x| &x.had).chain(self.fresh.values().flat_map(|f| &f.had)).all(|l| (*l as usize) < self.lessons.len());
606        checks && next as usize == self.applies.len() && applies && gate && past && had
607    }
608
609    /// Every folded session's delivery tallies as (delivered, not), each weighted by its age in
610    /// sessions under half-life `h` (select/policy.rs `decay`), the compacted ones included (`Past`,
611    /// kept for the half-lives a fit chooses from, `policy::HALF_LIVES`).
612    #[must_use]
613    pub fn tallies(&self, h: Option<u32>) -> [(f64, f64); TALLIES] {
614        let now = self.sessions.len() as u32;
615        let mut sums = [(0.0, 0.0); TALLIES];
616        if let Some(past) = policy::HALF_LIVES.iter().position(|x| *x == h).and_then(|i| self.past.tally.get(i)) {
617            let w = policy::decay(now, h);
618            for (s, p) in sums.iter_mut().zip(past) {
619                *s = (w * p.0, w * p.1);
620            }
621        }
622        for (t, x) in self.sessions.iter().enumerate() {
623            weigh(&mut sums, &x.tally, policy::decay(now.saturating_sub(t as u32), h));
624        }
625        sums
626    }
627
628    /// Sessions with graded checks, and their graded checks, the compacted ones included (`Past`):
629    /// what spend per session divides by (top.rs).
630    #[must_use]
631    pub fn checked(&self) -> (u64, u64) {
632        let kept = self.sessions.iter().filter(|x| x.checks > 0);
633        (u64::from(self.past.sessions) + kept.clone().count() as u64, self.past.checks + kept.map(|x| u64::from(x.checks)).sum::<u64>())
634    }
635}
636
637/// The lookups a fold needs, rebuilt from the summary at its start (never stored).
638#[derive(Default)]
639struct Index {
640    sessions: HashMap<String, u32>,
641    lessons: HashMap<String, u32>,
642    models: HashMap<String, u32>,
643    judges: HashMap<String, u32>,
644    checks: HashMap<String, u32>,
645    /// (tick, lesson) → its applies, oldest first.
646    by_lesson: HashMap<(u32, u32), Vec<u32>>,
647    /// tick → its checks not yet tallied.
648    untallied: HashMap<u32, Vec<u32>>,
649    /// tick → its delivered applies without a verdict.
650    open: HashMap<u32, u32>,
651}
652
653impl Index {
654    fn of(s: &Summary) -> Index {
655        let table = |v: &[String]| v.iter().enumerate().map(|(i, x)| (x.clone(), i as u32)).collect::<HashMap<_, _>>();
656        let mut ix = Index {
657            sessions: s.sessions.iter().enumerate().map(|(i, x)| (x.id.clone(), i as u32)).collect(),
658            lessons: table(&s.lessons),
659            models: table(&s.models),
660            judges: table(&s.judges),
661            ..Index::default()
662        };
663        for (ci, c) in s.checks.iter().enumerate() {
664            if !c.id.is_empty() {
665                ix.checks.insert(c.id.clone(), ci as u32);
666            }
667            if !c.tallied {
668                ix.untallied.entry(c.tick).or_default().push(ci as u32);
669            }
670            for i in c.first..c.first + u32::from(c.n) {
671                let Some(a) = s.applies.get(i as usize) else { continue };
672                ix.by_lesson.entry((c.tick, a.lesson)).or_default().push(i);
673                if waiting(a) {
674                    *ix.open.entry(c.tick).or_default() += 1;
675                }
676            }
677        }
678        ix
679    }
680}
681
682/// Is `a` delivered (or drawn as a placebo) and still without a verdict?
683fn waiting(a: &Apply) -> bool {
684    a.label == Label::Open && a.route != Route::Not && !a.delivered.never()
685}
686
687/// The index of `name` in `table`, added when new.
688fn intern(table: &mut Vec<String>, ix: &mut HashMap<String, u32>, name: &str) -> u32 {
689    if let Some(&i) = ix.get(name) {
690        return i;
691    }
692    table.push(name.to_string());
693    ix.insert(name.to_string(), table.len() as u32 - 1);
694    table.len() as u32 - 1
695}
696
697/// The kinds the fold reads with serde; any other is skipped (module doc).
698const USED: [&str; 13] = [
699    outcome::GRADED,
700    outcome::ASKED,
701    outcome::DELIVERY,
702    outcome::ENDED,
703    outcome::CHANGED,
704    outcome::IGNORED,
705    outcome::UNCLEAR,
706    outcome::NOT_ARISEN,
707    outcome::REMAINS,
708    outcome::NOT_CORRECTED,
709    outcome::CORRECTED,
710    outcome::MISSED,
711    outcome::VETOED,
712];
713
714/// The kind at the head of an outcome line (`{"kind":"<kind>"`, as outcome.rs writes every record),
715/// or None for a line in another shape (read with serde instead).
716fn kind_of(line: &str) -> Option<&str> {
717    let rest = line.strip_prefix("{\"kind\":\"")?;
718    rest.split_once('"').map(|(k, _)| k)
719}
720
721/// `from` with the outcome lines of `text` (appended after `from.at`) folded in; a last line without
722/// its newline is left for the next fold.
723#[must_use]
724pub fn fold(text: &str, from: Summary) -> Summary {
725    let mut s = from;
726    let mut ix = Index::of(&s);
727    for raw in text.split_inclusive('\n') {
728        if !raw.ends_with('\n') {
729            break;
730        }
731        s.at += raw.len() as u64;
732        let mut h = blake3::Hasher::new();
733        h.update(s.hash.as_bytes());
734        h.update(raw.as_bytes());
735        // 16 hex digits (64 bits). Protocol: the chain `key` reads.
736        s.hash = h.finalize().to_hex()[..16].to_string();
737        s.line(raw.trim_end(), &mut ix);
738    }
739    s
740}
741
742impl Summary {
743    fn line(&mut self, line: &str, ix: &mut Index) {
744        let era = usize::from(self.era);
745        match kind_of(line) {
746            Some(outcome::REGRETTED) => {
747                self.signals[era].regrets += 1;
748                return;
749            }
750            Some(outcome::TESTED) => {
751                self.signals[era].tested += 1;
752                return;
753            }
754            Some(k) if !USED.contains(&k) => return,
755            _ => {}
756        }
757        let Ok(o) = serde_json::from_str::<Outcome>(line) else { return };
758        match o.kind.as_str() {
759            outcome::GRADED => self.graded(&o, ix),
760            outcome::CHANGED => self.verdict(&o, Label::Changed, ix),
761            outcome::IGNORED => self.verdict(&o, Label::Ignored, ix),
762            outcome::UNCLEAR => self.verdict(&o, Label::Unclear, ix),
763            outcome::NOT_ARISEN => self.verdict(&o, Label::NotArisen, ix),
764            outcome::REMAINS => self.verdict(&o, Label::Remains, ix),
765            outcome::NOT_CORRECTED => self.settled(&o, ix),
766            outcome::ASKED | outcome::DELIVERY => self.delivered(&o, ix),
767            // A session without a graded check or settle has nothing to tally: no entry for it.
768            outcome::ENDED => {
769                if let Some(&tick) = ix.sessions.get(&o.session) {
770                    self.end(tick, ix);
771                }
772            }
773            outcome::CORRECTED => self.signals[era].corrections += 1,
774            // The `applied_then_corrected` a regret writes (its `cause` the regret) is the regret again,
775            // counted under regrets (memory.rs `is_correction`).
776            outcome::MISSED if o.cause.is_empty() => self.signals[era].corrections += 1,
777            outcome::VETOED => self.signals[era].vetoes += 1,
778            outcome::REGRETTED => self.signals[era].regrets += 1,
779            outcome::TESTED => self.signals[era].tested += 1,
780            _ => {}
781        }
782    }
783
784    /// The tick of `session`, a new one when it first appears (past `MAX_SESSIONS`, after a compaction),
785    /// with what it was shown before (`fresh`).
786    fn tick(&mut self, session: &str, ix: &mut Index) -> u32 {
787        if let Some(&t) = ix.sessions.get(session) {
788            return t;
789        }
790        if self.sessions.len() >= MAX_SESSIONS {
791            self.compact(ix, 0, MAX_SESSIONS / 4);
792        }
793        let had = self.fresh.remove(session).map(|f| f.had).unwrap_or_default();
794        self.sessions.push(Session { id: session.to_string(), had, ..Session::default() });
795        let t = self.sessions.len() as u32 - 1;
796        ix.sessions.insert(session.to_string(), t);
797        self.signals[usize::from(self.era)].sessions += 1;
798        t
799    }
800
801    fn graded(&mut self, o: &Outcome, ix: &mut Index) {
802        let tick = self.tick(&o.session, ix);
803        self.sessions[tick as usize].checks += 1;
804        let stop = o.channel != "live";
805        let spent = &mut self.spend[if stop { STOP } else { LIVE }];
806        spent.calls += 1;
807        spent.rounds += u64::from(stop && !o.chosen.is_empty());
808        spent.usd += o.usd;
809        // A learned decision the fixed rule took over (hook.rs `decide`), by its reason (one this binary
810        // does not know is not counted).
811        if let Some(i) = FELL_BACK.iter().position(|r| *r == o.reason) {
812            self.fell_back[i] += 1;
813        }
814        // A failed call applied nothing because it answered nothing: its pool says nothing of the yield.
815        if o.shown > 0 && !o.failed {
816            self.pool.shown += u64::from(o.shown);
817            self.pool.tokens += u64::from(o.tokens);
818            let bin = |k: u64| (u64::BITS - (k + 1).leading_zeros() - 1) as usize;
819            let last = bin(u64::from(o.shown) - 1);
820            if self.pool.bins.len() <= last {
821                self.pool.bins.resize(last + 1, (0, 0));
822            }
823            for (b, e) in self.pool.bins.iter_mut().enumerate().take(last + 1) {
824                let (lo, hi) = ((1u64 << b) - 1, (1u64 << (b + 1)) - 1);
825                e.0 += u64::from(o.shown).min(hi) - lo;
826            }
827            for &f in o.fill.iter().take(o.lessons.len()).filter(|f| u32::from(**f) < o.shown) {
828                if let Some(e) = self.pool.bins.get_mut(bin(u64::from(f))) {
829                    e.1 += 1;
830                }
831            }
832            // The tail sample, past the pool (module doc): each a shown lesson at its position.
833            for (l, f) in tail_of(o) {
834                let b = bin(u64::from(f));
835                if self.pool.bins.len() <= b {
836                    self.pool.bins.resize(b + 1, (0, 0));
837                }
838                let applied = u64::from(o.lessons.iter().any(|x| x == l));
839                let e = &mut self.pool.bins[b];
840                e.0 += 1;
841                e.1 += applied;
842                self.pool.tail.0 += 1;
843                self.pool.tail.1 += applied;
844            }
845        }
846        if o.dur_ms > 0 && o.tokens > 0 {
847            if self.pool.latency.len() >= LATENCY_KEEP {
848                self.pool.latency.remove(0);
849            }
850            // At the pool's tokens, as a budget is predicted (`pool_feasible`): the boundary's and the
851            // tail's (`xtokens`) grow with the store, not the budget, and are in the fit's intercept.
852            self.pool.latency.push((o.tokens, u32::try_from(o.dur_ms).unwrap_or(u32::MAX)));
853        }
854        // A stop's call against its harness's stop budget; a name borg does not know has no budget, and
855        // would let a log grow the cache's keys.
856        if stop && o.dur_ms > 0 && crate::harness::ALL.iter().any(|h| h.name() == o.harness) {
857            let calls = self.pool.stops.entry(o.harness.clone()).or_default();
858            if calls.len() >= LATENCY_KEEP {
859                calls.remove(0);
860            }
861            calls.push((u32::try_from(o.dur_ms).unwrap_or(u32::MAX), o.failed));
862        }
863        let learned = o.policy == "learned";
864        self.era |= learned;
865        // Malformed, or past what a check's count holds (`Check::n`): no applies.
866        let Ok(n) = u16::try_from(o.lessons.len()) else { return };
867        if o.failed || n == 0 || o.grades.len() != o.lessons.len() {
868            return;
869        }
870        let free = outcome::free_of(o);
871        let model = format!("{}|{}", o.model, o.prompt_hash);
872        let model = intern(&mut self.models, &mut ix.models, &model);
873        let ci = self.checks.len() as u32;
874        let first = self.applies.len() as u32;
875        let pending = u16::try_from(ix.open.get(&tick).copied().unwrap_or(0)).unwrap_or(u16::MAX);
876        let mut routes = Vec::with_capacity(o.lessons.len());
877        for (i, l) in o.lessons.iter().enumerate() {
878            let route = if o.chosen.contains(l) {
879                Route::Ask
880            } else if free.contains(l) {
881                Route::Free
882            } else if o.placebo.contains(l) {
883                Route::Placebo
884            } else {
885                Route::Not
886            };
887            routes.push(route);
888            // Older records logged only `p`; a free lesson's was 1 under the fixed rule.
889            let pf = o.pf.get(i).copied().unwrap_or(f64::from(u8::from(route == Route::Free)));
890            let lesson = intern(&mut self.lessons, &mut ix.lessons, l);
891            let rank = o.ranks.get(i).copied().unwrap_or(i as u16);
892            let psi = if matches!(route, Route::Placebo | Route::Not) { o.psi } else { 0.0 };
893            // Either view suffices: the hook's state may hold a delivery the log has not yet, and the log
894            // one the state the hook read did not (a delivery during the model call).
895            let nofree = o.nofree.contains(l);
896            let had = nofree || self.sessions[tick as usize].had.binary_search(&lesson).is_ok();
897            let p = o.p.get(i).copied().unwrap_or(0.0);
898            let a = Apply { check: ci, lesson, grade: o.grades[i], rank, route, p, pf, psi, had, nofree, pending, ..Apply::default() };
899            if waiting(&a) {
900                *ix.open.entry(tick).or_default() += 1;
901            }
902            self.applies.push(a);
903            ix.by_lesson.entry((tick, lesson)).or_default().push(first + i as u32);
904        }
905        let count = |r: Route| u16::try_from(routes.iter().filter(|x| **x == r).count()).unwrap_or(u16::MAX);
906        self.checks.push(Check {
907            id: o.check.clone(),
908            tick,
909            ms: o.ms,
910            stop: o.channel == "stop",
911            learned,
912            tokens: o.tokens,
913            model,
914            first,
915            n,
916            m_q: count(Route::Ask),
917            m_f: count(Route::Free),
918            tallied: false,
919            xk: None,
920            xs: None,
921        });
922        if learned {
923            self.count_test(o, ci as usize, tick);
924        }
925        if !o.check.is_empty() {
926            ix.checks.insert(o.check.clone(), ci);
927        }
928        ix.untallied.entry(tick).or_default().push(ci);
929        if learned && self.fit.model.is_some() {
930            let units: Vec<super::Unit> =
931                o.lessons.iter().zip(&o.grades).enumerate().map(|(r, (l, g))| super::Unit { lesson: l, grade: *g, rank: r }).collect();
932            let check = policy::Check { session: &o.session, stop: o.channel == "stop", tokens: o.tokens };
933            // Against the fixed rule the decision was gated against: under the lessons it had.
934            policy::ledger_add_without(&self.fit, &mut self.gate, &check, &units, &routes, &o.nofree);
935        }
936        if self.applies.len() > MAX_APPLIES {
937            self.compact(ix, MAX_APPLIES / 4, 0);
938        }
939    }
940
941    /// The question-count test at the learned check `ci` of record `o` (module doc): a drawn count
942    /// counted by its k and s and marked on the check, its session counted once; a refusal also by its k.
943    fn count_test(&mut self, o: &Outcome, ci: usize, tick: u32) {
944        let Some(k) = o.xk.filter(|k| *k as usize <= o.lessons.len()) else { return };
945        let source = o.source.as_str();
946        if ![policy::EXPERIMENT, policy::EXPERIMENT_CONTROL, policy::GATE].contains(&source) {
947            return;
948        }
949        // Every source of the test logs its draw's s (select/policy.rs `choose`): a record without one
950        // is not the test's.
951        let Some(s) = o.xs.filter(|s| *s <= k) else { return };
952        let e = &mut self.experiment;
953        if source == policy::GATE {
954            match e.refused.binary_search_by_key(&k, |c| c.0) {
955                Ok(i) => e.refused[i].1 += 1,
956                Err(i) => e.refused.insert(i, (k, 1)),
957            }
958        }
959        match e.ran.binary_search_by_key(&(k, s), |c| (c.0, c.1)) {
960            Ok(i) => e.ran[i].2 += 1,
961            Err(i) => e.ran.insert(i, (k, s, 1)),
962        }
963        e.control += u64::from(source == policy::EXPERIMENT_CONTROL);
964        (self.checks[ci].xk, self.checks[ci].xs) = (Some(k), Some(s));
965        let x = &mut self.sessions[tick as usize];
966        e.sessions += u32::from(!x.tested);
967        x.tested = true;
968    }
969
970    /// The apply of `lesson` that a result in `o` belongs to: in its check by id, else (an older
971    /// record) the session's last graded check before it that graded the lesson on one of `routes`.
972    fn apply_of(&self, o: &Outcome, lesson: &str, routes: &[Route], ix: &Index) -> Option<usize> {
973        let l = *ix.lessons.get(lesson)?;
974        if !o.check.is_empty() {
975            let c = &self.checks[*ix.checks.get(&o.check)? as usize];
976            return (c.first..c.first + u32::from(c.n)).map(|i| i as usize).find(|&i| self.applies[i].lesson == l && routes.contains(&self.applies[i].route));
977        }
978        let tick = *ix.sessions.get(&o.session)?;
979        ix.by_lesson.get(&(tick, l))?.iter().rev().map(|&i| i as usize).find(|&i| {
980            let a = &self.applies[i];
981            routes.contains(&a.route) && self.checks[a.check as usize].ms < o.ms
982        })
983    }
984
985    /// The routes a result on channel stop names in its check: a stop check's questions; a live check's
986    /// questions waiting for the stop, and its hand-overs in place of questions the stop asked in their
987    /// stead (hook.rs `stop_ask`: `#borg-routed`, a delivery with the reason "in place of a hand-over"),
988    /// whose apply stays the free one its check decided, as the question-count test drew it (its
989    /// verdict and delivery land there: intent to treat). A lesson is on one route in its check.
990    fn stop_routes(&self, o: &Outcome, ix: &Index) -> &'static [Route] {
991        let live = ix.checks.get(&o.check).is_some_and(|&ci| self.checks.get(ci as usize).is_some_and(|c| !c.stop));
992        if live { &[Route::Ask, Route::Free] } else { &[Route::Ask] }
993    }
994
995    /// Whether check `ci` graded a 3: a stop may ask its live questions while they wait (hook.rs
996    /// `Verdict::Ask`), and without one no stop does.
997    fn three(&self, ci: usize) -> bool {
998        let Some(c) = self.checks.get(ci) else { return false };
999        self.applies.get(c.first as usize..c.first as usize + usize::from(c.n)).is_some_and(|a| a.iter().any(|a| a.grade == 3))
1000    }
1001
1002    /// Sets apply `i`'s label to `next` when it yields to it, keeping the session's open count.
1003    fn label(&mut self, i: usize, next: Label, ix: &mut Index) {
1004        let a = &self.applies[i];
1005        if !a.label.yields_to(next) {
1006            return;
1007        }
1008        let tick = self.checks[a.check as usize].tick;
1009        let before = waiting(a);
1010        self.applies[i].label = next;
1011        if before
1012            && !waiting(&self.applies[i])
1013            && let Some(n) = ix.open.get_mut(&tick)
1014        {
1015            *n = n.saturating_sub(1);
1016        }
1017    }
1018
1019    fn verdict(&mut self, o: &Outcome, label: Label, ix: &mut Index) {
1020        if o.channel == "early" {
1021            if !label.pending() {
1022                self.early.judged += 1;
1023                self.early.changed += u32::from(label == Label::Changed);
1024            }
1025            return;
1026        }
1027        let routes: &[Route] = match o.channel.as_str() {
1028            outcome::PLACEBO => &[Route::Placebo],
1029            "stop" => self.stop_routes(o, ix),
1030            "live" => &[Route::Ask],
1031            "queued" => &[Route::Free],
1032            _ => &[Route::Ask, Route::Free],
1033        };
1034        for l in &o.lessons {
1035            let Some(i) = self.apply_of(o, l, routes, ix) else { continue };
1036            self.label(i, label, ix);
1037            if self.applies[i].label == label {
1038                self.applies[i].jv = o.judge;
1039            }
1040            if !label.pending() {
1041                let model = self.models.get(self.checks[self.applies[i].check as usize].model as usize).cloned().unwrap_or_default();
1042                // The session-end judge (outcome.rs `AT_END`) is its own instrument: another prompt, the
1043                // whole session's work in view, after every check. A check's verdict and an older record
1044                // have no `at`: their level is as before.
1045                let at = if o.at.is_empty() { String::new() } else { format!("@{}", o.at) };
1046                let key = format!("v{}{at}|{model}", o.judge);
1047                self.applies[i].judge = Some(intern(&mut self.judges, &mut ix.judges, &key));
1048                self.applies[i].stripped = o.stripped;
1049            }
1050        }
1051    }
1052
1053    /// A settle record: the session ended (`end`); a lesson it settles without a verdict (an unjudged
1054    /// reason) is censored. A settle that names the binary that wrote it (0.7.3 on, outcome.rs
1055    /// `settle_with`) also censors every delivered apply of the lesson in the session still without a
1056    /// verdict, whatever the reason: a later delivery took its place in the judge's list (hook.rs
1057    /// `pending`, the latest delivery wins), so none of them is judged now, and a lesson handed over
1058    /// free and then asked, or asked and then handed over, leaves no delivered apply open in an ended
1059    /// session, which the fit (policy.rs `judged`) and the readout (replay.rs `unit_outcome`) would
1060    /// skip. An older settle folds as it did when its hooks decided on it, so the replay (P1) still
1061    /// reproduces their decisions. One never delivered (`Delivered::Unknown`) stays as it was.
1062    fn settled(&mut self, o: &Outcome, ix: &mut Index) {
1063        let tick = self.tick(&o.session, ix);
1064        self.end(tick, ix);
1065        for l in &o.lessons {
1066            if o.reason != outcome::UNJUDGED_NONE
1067                && let Some(i) = self.apply_of(o, l, &[Route::Ask, Route::Free, Route::Placebo], ix)
1068            {
1069                self.label(i, Label::Censored, ix);
1070            }
1071            if o.version.is_empty() {
1072                continue;
1073            }
1074            let Some(&lesson) = ix.lessons.get(l) else { continue };
1075            let open: Vec<usize> = ix
1076                .by_lesson
1077                .get(&(tick, lesson))
1078                .into_iter()
1079                .flatten()
1080                .map(|&i| i as usize)
1081                .filter(|&i| self.applies[i].delivered == Delivered::Yes && waiting(&self.applies[i]))
1082                .collect();
1083            for i in open {
1084                self.label(i, Label::Censored, ix);
1085            }
1086        }
1087    }
1088
1089    /// The end of session `tick` (module doc): its checks not yet tallied count now.
1090    fn end(&mut self, tick: u32, ix: &mut Index) {
1091        self.sessions[tick as usize].ended = true;
1092        for ci in ix.untallied.remove(&tick).unwrap_or_default() {
1093            self.tally(ci as usize);
1094        }
1095    }
1096
1097    /// Counts check `ci`'s applies into its session's delivery tallies (`counted`).
1098    fn tally(&mut self, ci: usize) {
1099        let three = self.three(ci);
1100        let c = &mut self.checks[ci];
1101        c.tallied = true;
1102        let (tick, stop, range) = (c.tick as usize, c.stop, c.first as usize..c.first as usize + usize::from(c.n));
1103        for i in range {
1104            let Some(a) = self.applies.get(i) else { continue };
1105            for (which, hit) in counted(a, stop, three).into_iter().flatten() {
1106                let t = &mut self.sessions[tick].tally[which];
1107                t.0 += 1;
1108                t.1 += u32::from(hit);
1109            }
1110        }
1111    }
1112
1113    /// Changes apply `i` by `set` (its delivery, never its route or `had`); when its check is tallied
1114    /// already (its session ended, then resumed), its counts in the tallies move with it.
1115    fn update(&mut self, i: usize, set: impl FnOnce(&mut Apply)) {
1116        let Some(ci) = self.applies.get(i).map(|a| a.check as usize) else { return };
1117        let Some(c) = self.checks.get(ci) else { return };
1118        let (tallied, stop, tick, three) = (c.tallied, c.stop, c.tick as usize, self.three(ci));
1119        let before = counted(&self.applies[i], stop, three);
1120        set(&mut self.applies[i]);
1121        let after = counted(&self.applies[i], stop, three);
1122        let Some(x) = self.sessions.get_mut(tick).filter(|_| tallied) else { return };
1123        for (b, a) in before.into_iter().zip(after) {
1124            match (b, a) {
1125                (Some((which, was)), Some((_, is))) if was != is => {
1126                    let hits = &mut x.tally[which].1;
1127                    *hits = if is { *hits + 1 } else { hits.saturating_sub(1) };
1128                }
1129                // Out of the tally (a superseded ask, `counted`): its count goes with it.
1130                (Some((which, was)), None) => {
1131                    let t = &mut x.tally[which];
1132                    t.0 = t.0.saturating_sub(1);
1133                    t.1 = t.1.saturating_sub(u32::from(was));
1134                }
1135                _ => {}
1136            }
1137        }
1138    }
1139
1140    /// `lesson` shown to `session` (module doc: what a session had): into the `Session::had` of its tick,
1141    /// or, without one yet, its `fresh` entry (past `MAX_FRESH`, the oldest quarter go first). Its index.
1142    fn show(&mut self, tick: Option<u32>, session: &str, lesson: &str, ix: &mut Index) -> u32 {
1143        let l = intern(&mut self.lessons, &mut ix.lessons, lesson);
1144        let had = match tick {
1145            Some(t) => &mut self.sessions[t as usize].had,
1146            None => {
1147                if self.fresh.len() >= MAX_FRESH && !self.fresh.contains_key(session) {
1148                    let mut seqs: Vec<u64> = self.fresh.values().map(|f| f.seq).collect();
1149                    seqs.sort_unstable();
1150                    let keep = seqs[MAX_FRESH / 4];
1151                    self.fresh.retain(|_, f| f.seq >= keep);
1152                }
1153                let seq = self.at;
1154                &mut self.fresh.entry(session.to_string()).or_insert_with(|| Fresh { seq, had: Vec::new() }).had
1155            }
1156        };
1157        if let Err(at) = had.binary_search(&l) {
1158            had.insert(at, l);
1159        }
1160        l
1161    }
1162
1163    /// A delivery (outcome.rs `delivery` with its route, or an older `asked` record): the decided
1164    /// apply's fate; and, shown to the drone (not a placebo), the lesson the session had (`show`) and
1165    /// every apply of it an earlier check of the session left for later now delivered later. A
1166    /// `never_delivered` one ends the session.
1167    fn delivered(&mut self, o: &Outcome, ix: &mut Index) {
1168        let (fate, routes): (Delivered, &[Route]) = match (o.kind.as_str(), o.route.as_str()) {
1169            (outcome::DELIVERY, outcome::ROUTE_ASKED) if o.channel == "stop" => (Delivered::Yes, self.stop_routes(o, ix)),
1170            (outcome::DELIVERY, outcome::ROUTE_ASKED) => (Delivered::Yes, &[Route::Ask]),
1171            (outcome::DELIVERY, outcome::ROUTE_FREE) => (Delivered::Yes, &[Route::Free]),
1172            (outcome::DELIVERY, outcome::ROUTE_PLACEBO) => (Delivered::Yes, &[Route::Placebo]),
1173            (outcome::DELIVERY, outcome::ROUTE_SUPERSEDED) => (Delivered::Superseded, &[Route::Ask]),
1174            (outcome::DELIVERY, _) => (Delivered::Dropped, &[Route::Ask, Route::Free, Route::Placebo]),
1175            (_, _) if o.channel == "queued" => (Delivered::Yes, &[Route::Free]),
1176            (_, _) if o.channel == "stop" => (Delivered::Yes, self.stop_routes(o, ix)),
1177            (_, _) if o.channel == "live" => (Delivered::Yes, &[Route::Ask]),
1178            _ => (Delivered::Yes, &[]),
1179        };
1180        // A placebo is judged, never shown: not had, and no later delivery of anything.
1181        let shown = fate == Delivered::Yes && o.route != outcome::ROUTE_PLACEBO;
1182        let Some(&tick) = ix.sessions.get(&o.session) else {
1183            // An early delivery before the session's first check: what that check's applies had.
1184            for l in o.lessons.iter().filter(|_| shown) {
1185                self.show(None, &o.session, l, ix);
1186            }
1187            return;
1188        };
1189        for l in &o.lessons {
1190            let own = if routes.is_empty() { None } else { self.apply_of(o, l, routes, ix) };
1191            if let Some(i) = own {
1192                let before = waiting(&self.applies[i]);
1193                let c = &self.checks[self.applies[i].check as usize];
1194                let waited = fate == Delivered::Yes && o.channel == "stop" && !c.stop && self.applies[i].route == Route::Ask;
1195                self.update(i, |a| {
1196                    a.delivered = fate;
1197                    a.waited |= waited;
1198                });
1199                let after = waiting(&self.applies[i]);
1200                let open = ix.open.entry(tick).or_default();
1201                match (before, after) {
1202                    (true, false) => *open = open.saturating_sub(1),
1203                    (false, true) => *open += 1,
1204                    _ => {}
1205                }
1206            }
1207            if !shown {
1208                continue;
1209            }
1210            let lesson = self.show(Some(tick), &o.session, l, ix);
1211            let own_check = own.map(|i| self.applies[i].check);
1212            for &i in ix.by_lesson.get(&(tick, lesson)).map_or(&[][..], Vec::as_slice) {
1213                let a = &self.applies[i as usize];
1214                if a.route == Route::Not && Some(a.check) != own_check && self.checks[a.check as usize].ms < o.ms {
1215                    self.update(i as usize, |a| a.later = true);
1216                }
1217            }
1218        }
1219        // Never delivered because the session ended first: the end hook's, before its `ended`.
1220        if o.kind == outcome::DELIVERY && o.route == outcome::ROUTE_NEVER {
1221            self.end(tick, ix);
1222        }
1223    }
1224
1225    /// Compaction (module doc): drops the oldest checks holding at least `applies` applies; then the
1226    /// oldest sessions, at least `sessions` of them and every one before the first kept check's, with
1227    /// their checks, but none of the newest `policy::FIT_SESSIONS` and an even count (the monitor's
1228    /// halves are the ticks' parities, select/policy.rs `monitor`), into `Past`; and the lessons, models
1229    /// and judges nothing kept names (an apply, or what a kept or fresh session had).
1230    fn compact(&mut self, ix: &mut Index, applies: usize, sessions: usize) {
1231        let cut = self.checks.iter().position(|c| c.first as usize >= applies).unwrap_or(self.checks.len());
1232        let len = self.sessions.len();
1233        let oldest = self.checks[cut..].iter().map(|c| c.tick as usize).min().unwrap_or(len);
1234        let gone = oldest.max(sessions).min(len.saturating_sub(policy::FIT_SESSIONS));
1235        let gone = gone - gone % 2;
1236        self.past.add(&self.sessions[..gone]);
1237        self.sessions.drain(..gone);
1238        let (checks, all) = (std::mem::take(&mut self.checks), std::mem::take(&mut self.applies));
1239        for (ci, mut c) in checks.into_iter().enumerate() {
1240            if ci < cut || (c.tick as usize) < gone {
1241                continue;
1242            }
1243            let of = all.get(c.first as usize..c.first as usize + usize::from(c.n)).unwrap_or_default();
1244            let check = self.checks.len() as u32;
1245            (c.first, c.tick) = (self.applies.len() as u32, c.tick - gone as u32);
1246            self.applies.extend(of.iter().map(|a| Apply { check, ..a.clone() }));
1247            self.checks.push(c);
1248        }
1249        self.dropped += (all.len() - self.applies.len()) as u64;
1250        let had = self.sessions.iter().flat_map(|x| &x.had).chain(self.fresh.values().flat_map(|f| &f.had)).copied();
1251        let lessons = prune(&mut self.lessons, self.applies.iter().map(|a| a.lesson).chain(had));
1252        let judges = prune(&mut self.judges, self.applies.iter().filter_map(|a| a.judge));
1253        let models = prune(&mut self.models, self.checks.iter().map(|c| c.model));
1254        let new = |map: &[u32], i: u32| map.get(i as usize).copied().unwrap_or(i);
1255        for a in &mut self.applies {
1256            (a.lesson, a.judge) = (new(&lessons, a.lesson), a.judge.map(|j| new(&judges, j)));
1257        }
1258        // `prune` keeps the order, so each `had` stays sorted.
1259        for had in self.sessions.iter_mut().map(|x| &mut x.had).chain(self.fresh.values_mut().map(|f| &mut f.had)) {
1260            for l in had.iter_mut() {
1261                *l = new(&lessons, *l);
1262            }
1263        }
1264        for c in &mut self.checks {
1265            c.model = new(&models, c.model);
1266        }
1267        *ix = Index::of(self);
1268    }
1269}
1270
1271/// The delivery tallies an apply of a stop's (else a live) check counts into, each with whether it
1272/// counts as delivered: a free lesson, by its check's channel; a lesson left for later, at a live check
1273/// only (ρ, select/policy.rs `Rates::later`, is shown only and is a lesson left for later during the
1274/// turn: a stop's arrives later only if a next prompt brings it again); a live question of a check
1275/// with a 3 (`three`), whether it reached the drone (during the turn, or waiting for the stop and asked
1276/// in its block) and whether it waited for the stop. None for an apply of a lesson its session had
1277/// (`Apply::had`; module doc: the rates are those of a lesson the session lacks, and a question of it
1278/// may still be delivered and judged), an ask at the stop (it blocks: certain), a live question of a
1279/// check without a 3 (no stop asks it: it arrives at a tool call or the next prompt, as a hand-over
1280/// does, and is valued at the free rate, select/policy.rs `Valuer::ask`), a placebo, and a superseded
1281/// ask (`Delivered::Superseded`: the newer check's ask of the lesson counts, once).
1282fn counted(a: &Apply, stop: bool, three: bool) -> [Option<(usize, bool)>; 2] {
1283    if a.had || a.delivered == Delivered::Superseded {
1284        return [None; 2];
1285    }
1286    match a.route {
1287        Route::Free => [Some((if stop { FREE_STOP } else { FREE_LIVE }, a.delivered == Delivered::Yes)), None],
1288        Route::Not if !stop => [Some((LATER, a.later)), None],
1289        Route::Ask if !stop && three => [Some((ASK_LIVE, a.delivered == Delivered::Yes)), Some((WAIT, a.waited))],
1290        _ => [None; 2],
1291    }
1292}
1293
1294/// A graded record's tail sample (module doc) with each lesson's fill position: `fill` past the
1295/// applies' (aligned with `tail`), each one past the pool (`shown`). Empty without a tail, and for a
1296/// record not in that shape or without its chance `pt` (0 < pt ≤ 1): no uniform sample then.
1297fn tail_of(o: &Outcome) -> Vec<(&str, u16)> {
1298    let at = o.fill.get(o.lessons.len()..).unwrap_or_default();
1299    let past = at.iter().all(|f| u32::from(*f) >= o.shown && *f != u16::MAX);
1300    if o.tail.is_empty() || at.len() != o.tail.len() || !past || !(o.pt > 0.0 && o.pt <= 1.0) {
1301        return Vec::new();
1302    }
1303    o.tail.iter().map(String::as_str).zip(at.iter().copied()).collect()
1304}
1305
1306/// `table` cut to the names `used` points at, in their order; and for each old index, its new one.
1307fn prune(table: &mut Vec<String>, used: impl Iterator<Item = u32>) -> Vec<u32> {
1308    let mut keep = vec![false; table.len()];
1309    for i in used {
1310        if let Some(k) = keep.get_mut(i as usize) {
1311            *k = true;
1312        }
1313    }
1314    let mut map = Vec::with_capacity(keep.len());
1315    for (name, k) in std::mem::take(table).into_iter().zip(keep) {
1316        map.push(table.len() as u32);
1317        if k {
1318            table.push(name);
1319        }
1320    }
1321    map
1322}
1323
1324/// The cache of the store whose lessons file is `lessons`.
1325#[must_use]
1326pub fn cache_path(lessons: &Path) -> PathBuf {
1327    lessons.with_file_name(CACHE)
1328}
1329
1330/// The auto-revert mark of that store.
1331#[must_use]
1332pub fn reverted_path(lessons: &Path) -> PathBuf {
1333    lessons.with_file_name(REVERTED)
1334}
1335
1336/// The summary of the store whose lessons file is `lessons` (module doc): the cache with the log's
1337/// new tail folded in, rebuilt in memory when it cannot be resumed. Never writes.
1338#[must_use]
1339pub fn load(lessons: &Path) -> Summary {
1340    read(lessons, true)
1341}
1342
1343/// `load` without a rebuild, for a hook that must stay cheap (an early delivery, `PostToolUse`): the
1344/// prior when the cache cannot be resumed.
1345#[must_use]
1346pub fn peek(lessons: &Path) -> Summary {
1347    read(lessons, false)
1348}
1349
1350/// The live check's and the stop's `load`: the summary, saved when it holds more than the cache did
1351/// (a fold of a new tail, a rebuild). A failed save costs the next hook the same fold, nothing else.
1352pub fn refresh(lessons: &Path) -> Summary {
1353    let (s, cached) = read_at(lessons, true);
1354    if cached != Some(s.at) {
1355        let _ = save(lessons, &s);
1356    }
1357    s
1358}
1359
1360fn read(lessons: &Path, rebuild_ok: bool) -> Summary {
1361    read_at(lessons, rebuild_ok).0
1362}
1363
1364/// `read`, and the `at` of the cache it resumed (None: none, or one it could not use).
1365fn read_at(lessons: &Path, rebuild_ok: bool) -> (Summary, Option<u64>) {
1366    let log = outcome::path_beside(lessons);
1367    let len = std::fs::metadata(&log).map_or(0, |m| m.len());
1368    let cached = std::fs::read(cache_path(lessons))
1369        .ok()
1370        .and_then(|b| serde_json::from_slice::<Summary>(&b).ok())
1371        .filter(|s| s.sv == SV && s.at <= len && s.consistent());
1372    let reverted = reverted_path(lessons).exists();
1373    let mut resumed = cached.as_ref().map(|s| s.at);
1374    let Some(mut s) = cached.or_else(|| rebuild_ok.then(|| rebuild(lessons))) else {
1375        return (Summary { monitor: Monitor { reverted, ..Monitor::default() }, ..Summary::default() }, None);
1376    };
1377    if s.at < len {
1378        match tail(&log, s.at) {
1379            Some(text) => s = fold(&text, s),
1380            None if rebuild_ok => (s, resumed) = (rebuild(lessons), None),
1381            None => (s, resumed) = (Summary::default(), None),
1382        }
1383    }
1384    // The mark is the revert, not the cache's copy of it: removing the mark resumes learning (`REVERTED`).
1385    s.monitor.reverted = reverted;
1386    (s, resumed)
1387}
1388
1389/// The log's bytes from `at`, or None when they cannot be read as text.
1390fn tail(log: &Path, at: u64) -> Option<String> {
1391    let mut f = std::fs::File::open(log).ok()?;
1392    f.seek(std::io::SeekFrom::Start(at)).ok()?;
1393    let mut bytes = Vec::new();
1394    f.read_to_end(&mut bytes).ok()?;
1395    String::from_utf8(bytes).ok()
1396}
1397
1398/// Writes `s` as the cache beside `lessons`: a temp file, then a rename, so a reader never sees half.
1399pub fn save(lessons: &Path, s: &Summary) -> std::io::Result<()> {
1400    let target = cache_path(lessons);
1401    let tmp = target.with_extension(format!("{}.tmp", std::process::id()));
1402    std::fs::write(&tmp, serde_json::to_vec(s).map_err(std::io::Error::other)?)?;
1403    std::fs::rename(&tmp, &target).inspect_err(|_| {
1404        let _ = std::fs::remove_file(&tmp);
1405    })
1406}
1407
1408/// The summary from the whole outcome log beside `lessons` (`borg debug select --rebuild`, a cache that
1409/// cannot be resumed), folded from the prior: no fit.
1410#[must_use]
1411pub fn rebuild(lessons: &Path) -> Summary {
1412    let text = std::fs::read_to_string(outcome::path_beside(lessons)).unwrap_or_default();
1413    fold(&text, Summary::default())
1414}
1415
1416/// `s` refitted (select/policy.rs `fit`) under an engineer's `budget` (pool tokens per session), with
1417/// the gate's ledger replayed under the new fit and the monitor read; a revert stays.
1418#[must_use]
1419pub fn with_fit(mut s: Summary, budget: Option<f64>) -> Summary {
1420    s.fit = policy::fit(&s, budget);
1421    s.gate = policy::ledger(&s);
1422    let sticky = s.monitor.reverted;
1423    s.monitor = policy::monitor(&s);
1424    s.monitor.reverted |= sticky;
1425    s
1426}
1427
1428/// `borg learn`: the summary beside `lessons` refitted (`with_fit`, the collective's budget from its
1429/// config, select.rs `budget_of`), its revert mark written when the monitor tripped, and the cache
1430/// saved.
1431pub fn refit(lessons: &Path) -> Summary {
1432    let s = with_fit(load(lessons), super::budget_of(lessons));
1433    if s.monitor.reverted {
1434        let _ = std::fs::write(reverted_path(lessons), format!("{}\n", serde_json::to_string(&s.monitor).unwrap_or_default()));
1435    }
1436    let _ = save(lessons, &s);
1437    s
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442    use super::*;
1443
1444    fn rec(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
1445        Outcome { kind: kind.into(), session: session.into(), lessons: lessons.iter().map(|l| l.to_string()).collect(), ms, ..Default::default() }
1446    }
1447
1448    /// A graded check: `ask`ed and `free` lessons by id, the rest left for later.
1449    fn graded(session: &str, check: &str, lessons: &[&str], grades: &[u8], ask: &[&str], free: &[&str], ms: i64) -> Outcome {
1450        let owned = |v: &[&str]| v.iter().map(|l| l.to_string()).collect::<Vec<_>>();
1451        Outcome {
1452            grades: grades.to_vec(),
1453            ranks: (0..lessons.len() as u16).collect(),
1454            applied: lessons.len() as u16,
1455            chosen: owned(ask),
1456            free: owned(free),
1457            channel: "live".into(),
1458            check: check.into(),
1459            policy: "learned".into(),
1460            p: lessons.iter().map(|l| f64::from(u8::from(ask.contains(l)))).collect(),
1461            pf: lessons.iter().map(|l| f64::from(u8::from(free.contains(l)))).collect(),
1462            shown: 20,
1463            tokens: 6000,
1464            fill: (0..lessons.len() as u16).map(|i| 3 * i).collect(),
1465            dur_ms: 9000,
1466            ..rec(outcome::GRADED, session, lessons, ms)
1467        }
1468    }
1469
1470    fn verdict(kind: &str, session: &str, lesson: &str, check: &str, channel: &str, ms: i64) -> Outcome {
1471        Outcome { check: check.into(), channel: channel.into(), judge: 2, ..rec(kind, session, &[lesson], ms) }
1472    }
1473
1474    fn delivery(session: &str, lesson: &str, check: &str, route: &str, ms: i64) -> Outcome {
1475        Outcome { check: check.into(), route: route.into(), ..rec(outcome::DELIVERY, session, &[lesson], ms) }
1476    }
1477
1478    /// `graded` at the stop.
1479    fn stop(session: &str, check: &str, lessons: &[&str], grades: &[u8], ask: &[&str], free: &[&str], ms: i64) -> Outcome {
1480        Outcome { channel: "stop".into(), ..graded(session, check, lessons, grades, ask, free, ms) }
1481    }
1482
1483    /// What the end hook writes of a queue line still waiting: `never_delivered`.
1484    fn never(session: &str, check: &str, lessons: &[&str], ms: i64) -> Outcome {
1485        Outcome { check: check.into(), route: outcome::ROUTE_NEVER.into(), channel: "queued".into(), ..rec(outcome::DELIVERY, session, lessons, ms) }
1486    }
1487
1488    fn lines(log: &[Outcome]) -> String {
1489        log.iter().map(|o| format!("{}\n", serde_json::to_string(o).unwrap())).collect()
1490    }
1491
1492    /// A log with every kind the fold reads, older records without check ids among them.
1493    fn log() -> Vec<Outcome> {
1494        let legacy = Outcome {
1495            check: String::new(),
1496            policy: String::new(),
1497            pf: Vec::new(),
1498            free: Vec::new(),
1499            ..graded("old", "", &["A", "B", "C"], &[2, 1, 1], &[], &[], 1)
1500        };
1501        let mut placebo = graded("s", "c3", &["A", "B", "C"], &[1, 1, 2], &[], &["C"], 40);
1502        placebo.placebo = vec!["A".into()];
1503        placebo.psi = 0.5;
1504        vec![
1505            legacy,
1506            Outcome { channel: "queued".into(), ..rec(outcome::ASKED, "old", &["A"], 2) },
1507            Outcome { channel: "queued".into(), ..rec(outcome::CHANGED, "old", &["A"], 3) },
1508            Outcome { channel: "queued".into(), ..rec(outcome::IGNORED, "old", &["B"], 4) },
1509            rec(outcome::NOT_CORRECTED, "old", &["C"], 5),
1510            graded("s", "c1", &["A", "B"], &[3, 2], &["A", "B"], &[], 10),
1511            delivery("s", "A", "c1", outcome::ROUTE_ASKED, 11),
1512            delivery("s", "B", "c1", outcome::ROUTE_DROPPED_DONE, 11),
1513            rec(outcome::REGRETTED, "s", &["A"], 12),
1514            Outcome { cause: "R1".into(), credit: 0.5, ..rec(outcome::REGRETTED, "s", &["B"], 12) },
1515            verdict(outcome::UNCLEAR, "s", "A", "c1", "live", 13),
1516            verdict(outcome::CHANGED, "s", "A", "c1", "live", 14),
1517            graded("s", "c2", &["D", "E"], &[1, 2], &[], &["E"], 20),
1518            Outcome { channel: "early".into(), ..rec(outcome::CHANGED, "s", &["Q"], 21) },
1519            delivery("s", "E", "c2", outcome::ROUTE_FREE, 22),
1520            Outcome { check: "c9".into(), channel: "live".into(), ..rec(outcome::ASKED, "s", &["D"], 30) },
1521            placebo,
1522            delivery("s", "A", "c3", outcome::ROUTE_PLACEBO, 41),
1523            verdict(outcome::IGNORED, "s", "A", "c3", outcome::PLACEBO, 42),
1524            verdict(outcome::CHANGED, "s", "C", "c3", "queued", 43),
1525            rec(outcome::VETOED, "s", &["C"], 44),
1526            Outcome { reason: outcome::UNJUDGED_TRIES.into(), check: "c2".into(), ..rec(outcome::NOT_CORRECTED, "s", &["E"], 50) },
1527            rec(outcome::TESTED, "s", &["A"], 51),
1528            Outcome { kind: "graded".into(), failed: true, tokens: 6000, dur_ms: 30_000, ..graded("s", "c4", &[], &[], &[], &[], 52) },
1529        ]
1530    }
1531
1532    fn label_of(s: &Summary, check: &str, lesson: &str) -> (Route, Label, Delivered) {
1533        let c = s.checks.iter().find(|c| c.id == check).unwrap();
1534        let a = s.applies[c.first as usize..(c.first + u32::from(c.n)) as usize].iter().find(|a| s.lessons[a.lesson as usize] == lesson).unwrap();
1535        (a.route, a.label, a.delivered)
1536    }
1537
1538    #[test]
1539    fn results_join_their_check_by_id_and_older_ones_by_the_last_check_before() {
1540        let s = fold(&lines(&log()), Summary::default());
1541        assert_eq!(s.checks.len(), 4, "the failed check has no applies");
1542        // The older check: no id, its results by the session's last check before them, on the route
1543        // their channel names.
1544        let old = &s.applies[..3];
1545        // C settled by an older binary (no reason) without a verdict: censored.
1546        assert_eq!(
1547            old.iter().map(|a| (a.route, a.label)).collect::<Vec<_>>(),
1548            [(Route::Free, Label::Changed), (Route::Free, Label::Ignored), (Route::Free, Label::Censored)]
1549        );
1550        assert_eq!(old[0].delivered, Delivered::Yes);
1551        assert_eq!(old[0].pf, 1.0, "an older record's free lesson had pf 1");
1552        // A verdict supersedes an unclear one; a dropped ask stays without a verdict.
1553        assert_eq!(label_of(&s, "c1", "A"), (Route::Ask, Label::Changed, Delivered::Yes));
1554        assert_eq!(label_of(&s, "c1", "B"), (Route::Ask, Label::Open, Delivered::Dropped));
1555        // A placebo's verdict labels the placebo, never the free lesson beside it.
1556        assert_eq!(label_of(&s, "c3", "A"), (Route::Placebo, Label::Ignored, Delivered::Yes));
1557        assert_eq!(label_of(&s, "c3", "C"), (Route::Free, Label::Changed, Delivered::Unknown));
1558        // Settled without a verdict: censored; D was left for later by c2 and asked after it.
1559        assert_eq!(label_of(&s, "c2", "E").1, Label::Censored);
1560        let d = s.applies.iter().find(|a| s.lessons[a.lesson as usize] == "D").unwrap();
1561        assert!(d.later && d.route == Route::Not);
1562        // c2b leaves A for later; c3 then draws A as a placebo, judged but never shown: no later delivery.
1563        let mut more = log();
1564        more.insert(16, graded("s", "c2b", &["A"], &[1], &[], &[], 35));
1565        let s2 = fold(&lines(&more), Summary::default());
1566        assert!(!s2.applies[s2.checks.iter().find(|c| c.id == "c2b").unwrap().first as usize].later, "a placebo is no delivery");
1567        assert_eq!(s.applies.iter().find(|a| a.route == Route::Placebo).map(|a| a.psi), Some(0.5));
1568        assert_eq!((s.early.judged, s.early.changed), (1, 1));
1569        assert!(s.applies.iter().filter(|a| a.label == Label::Changed).all(|a| a.judge.is_some()));
1570        assert_eq!(s.judges, ["v0||", "v2||"], "the older verdicts' judge (version 0) and the new one's, each with its check's model");
1571        // Signals and tallies.
1572        assert_eq!((s.signals[1].regrets, s.signals[1].vetoes, s.signals[1].tested), (2, 1, 1));
1573        assert_eq!(s.sessions.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(), ["old", "s"]);
1574        assert!(s.sessions.iter().all(|x| x.ended));
1575        assert_eq!(s.sessions[1].tally[FREE_LIVE], (2, 1), "E delivered, C not");
1576        assert_eq!(s.sessions[1].tally[LATER], (2, 1), "c2's D delivered later, c3's B not");
1577        assert_eq!(s.pool.latency.len(), 5, "every timed call, the failed one too");
1578        assert_eq!(s.pool.bins[0], (4, 4), "position 0 shown by the four answered calls, applied in each");
1579        assert_eq!(s.pool.bins[2], (16, 6), "positions 3 to 6: fills 3 and 6 of the three-lesson checks, 3 of the two-lesson ones");
1580    }
1581
1582    /// An early delivery, as hook.rs `record_delivery` writes it.
1583    fn early(session: &str, lessons: &[&str], ms: i64) -> Outcome {
1584        Outcome { channel: "early".into(), ..rec(outcome::ASKED, session, lessons, ms) }
1585    }
1586
1587    /// Lines for the state round 0.6.0 added: a session shown a lesson before its first check, a
1588    /// check's `nofree` and tail sample, live questions' fates.
1589    fn more() -> Vec<Outcome> {
1590        let tailed = Outcome {
1591            nofree: vec!["N1".into()],
1592            tail: vec!["T1".into(), "T2".into()],
1593            pt: 0.5,
1594            fill: vec![0, 3, 25, 25, 40],
1595            ..graded("n", "n1", &["A", "N1", "T1"], &[3, 1, 2], &["A"], &["T1"], 61)
1596        };
1597        vec![
1598            early("n", &["N1"], 60),
1599            tailed,
1600            delivery("n", "A", "n1", outcome::ROUTE_ASKED, 62),
1601            delivery("n", "T1", "n1", outcome::ROUTE_FREE, 62),
1602            rec(outcome::ENDED, "n", &[], 63),
1603        ]
1604    }
1605
1606    #[test]
1607    fn a_fold_of_the_whole_equals_its_folds_at_every_line_boundary() {
1608        let text = lines(&[log(), more()].concat());
1609        // With a fit, so the gate's ledger folds too.
1610        let mut prior = Summary::default();
1611        let fitted = with_fit(fold(&text, Summary::default()), None);
1612        prior.fit = fitted.fit.clone();
1613        assert!(prior.fit.model.is_some(), "the log has labels");
1614        let whole = fold(&text, prior.clone());
1615        assert!(whole.gate.checks > 0, "learned checks reached the ledger");
1616        let mut at = 0;
1617        let mut one = prior.clone();
1618        for line in text.split_inclusive('\n') {
1619            let split = fold(&text[at + line.len()..], fold(&text[..at + line.len()], prior.clone()));
1620            assert_eq!(split, whole, "split after byte {}", at + line.len());
1621            one = fold(line, one);
1622            at += line.len();
1623        }
1624        assert_eq!(one, whole, "line by line");
1625        assert_eq!(whole.at, text.len() as u64);
1626        // A refit replays the same ledger the folds built.
1627        let refit = with_fit(whole.clone(), None);
1628        assert_eq!(refit.fit.key, whole.key());
1629    }
1630
1631    #[test]
1632    fn a_line_being_written_waits_for_its_newline() {
1633        let text = lines(&log());
1634        let cut = text.len() - 10;
1635        let s = fold(&text[..cut], Summary::default());
1636        let last_newline = text[..cut].rfind('\n').unwrap() + 1;
1637        assert_eq!(s.at, last_newline as u64);
1638        assert_eq!(fold(&text[s.at as usize..], s), fold(&text, Summary::default()));
1639    }
1640
1641    #[test]
1642    fn kinds_it_does_not_use_cost_a_substring() {
1643        // A regret counts without serde (this one would not parse); an unknown kind is never read.
1644        let text =
1645            [r#"{"kind":"regretted",broken"#, r#"{"kind":"something_new","session":"z"}"#, r#"{"kind":"graded","session":"s","lessons":[],"ms":1,"repo":""}"#]
1646                .map(|l| format!("{l}\n"))
1647                .concat();
1648        let text = text.as_str();
1649        let s = fold(text, Summary::default());
1650        assert_eq!(s.signals[0].regrets, 1);
1651        assert_eq!(s.sessions.len(), 1, "only the graded line registered a session");
1652        assert_eq!(kind_of("{\"kind\":\"asked\",\"x\":1}"), Some("asked"));
1653        assert_eq!(kind_of(" {\"kind\":\"asked\"}"), None);
1654    }
1655
1656    /// A temp store: its lessons file (never written) and the outcomes beside it.
1657    fn store(name: &str, text: &str) -> PathBuf {
1658        let d = std::env::temp_dir().join(format!("borg-summary-{name}-{}", std::process::id()));
1659        let _ = std::fs::remove_dir_all(&d);
1660        std::fs::create_dir_all(&d).unwrap();
1661        std::fs::write(d.join("outcomes.jsonl"), text).unwrap();
1662        d.join("lessons.jsonl")
1663    }
1664
1665    #[test]
1666    fn load_resumes_the_cache_and_rebuilds_what_it_cannot() {
1667        let text = lines(&log());
1668        let half = text[..text.len() / 2].rfind('\n').unwrap() + 1;
1669        let lessons = store("load", &text[..half]);
1670        let whole = fold(&text, Summary::default());
1671        // Saved at half the log, then the rest appended: loading folds only the tail.
1672        save(&lessons, &load(&lessons)).unwrap();
1673        std::fs::write(outcome::path_beside(&lessons), &text).unwrap();
1674        assert_eq!(load(&lessons), whole);
1675        assert_eq!(peek(&lessons), whole);
1676        // A corrupt cache: load rebuilds, peek takes the prior.
1677        std::fs::write(cache_path(&lessons), "{\"sv\":1,\"at\":").unwrap();
1678        assert_eq!(load(&lessons), whole);
1679        assert_eq!(peek(&lessons), Summary::default());
1680        // Another format, and a log shorter than the cache read (rewritten): rebuilt.
1681        let mut other = whole.clone();
1682        other.sv = SV + 1;
1683        save(&lessons, &other).unwrap();
1684        assert_eq!(load(&lessons), whole);
1685        let mut ahead = whole.clone();
1686        ahead.at += 1_000;
1687        save(&lessons, &ahead).unwrap();
1688        assert_eq!(load(&lessons), whole);
1689        // A cache that parses but points outside its tables is corrupt too: never indexed in a hook.
1690        let mut broken = whole.clone();
1691        broken.checks[1].first += 1;
1692        save(&lessons, &broken).unwrap();
1693        assert_eq!((load(&lessons), peek(&lessons)), (whole.clone(), Summary::default()));
1694        let mut broken = whole.clone();
1695        broken.applies[0].lesson = 99;
1696        save(&lessons, &broken).unwrap();
1697        assert_eq!(load(&lessons), whole);
1698        let mut broken = whole.clone();
1699        broken.sessions[1].had.push(99);
1700        save(&lessons, &broken).unwrap();
1701        assert_eq!(load(&lessons), whole, "what a session had points outside the lessons");
1702        assert!(whole.consistent() && Summary::default().consistent());
1703        // A refit summary, its monitor's bound unset before enough sessions, loads as saved (bit for
1704        // bit only as serde_json parses floats: without its `float_roundtrip` a few land 1 ULP off).
1705        let fitted = with_fit(whole.clone(), None);
1706        assert!(fitted.fit.model.is_some() && fitted.monitor.ucb.is_none());
1707        save(&lessons, &fitted).unwrap();
1708        let parsed: Summary = serde_json::from_slice(&serde_json::to_vec(&fitted).unwrap()).unwrap();
1709        assert_eq!(load(&lessons), parsed);
1710        // The revert mark is sticky: read on every load.
1711        std::fs::write(reverted_path(&lessons), "{}\n").unwrap();
1712        assert!(load(&lessons).monitor.reverted && peek(&lessons).monitor.reverted);
1713        // The mark is the revert, not the cache's copy of it (`refit` saves both): removing the mark
1714        // resumes learning.
1715        save(&lessons, &Summary { monitor: Monitor { reverted: true, ..fitted.monitor.clone() }, ..fitted.clone() }).unwrap();
1716        assert!(load(&lessons).monitor.reverted);
1717        std::fs::remove_file(reverted_path(&lessons)).unwrap();
1718        assert!(!load(&lessons).monitor.reverted && !peek(&lessons).monitor.reverted);
1719        // `refresh` saves only what it folded past the cache.
1720        std::fs::write(outcome::path_beside(&lessons), &text[..half]).unwrap();
1721        std::fs::remove_file(cache_path(&lessons)).unwrap();
1722        assert_eq!(refresh(&lessons).at, half as u64, "no cache: rebuilt and saved");
1723        // The same summary written pretty: a rewrite would make it compact again.
1724        let pretty = serde_json::to_vec_pretty(&peek(&lessons)).unwrap();
1725        std::fs::write(cache_path(&lessons), &pretty).unwrap();
1726        assert_eq!(refresh(&lessons).at, half as u64);
1727        assert_eq!(std::fs::read(cache_path(&lessons)).unwrap(), pretty, "nothing new: not rewritten");
1728        std::fs::write(outcome::path_beside(&lessons), &text).unwrap();
1729        assert_eq!(refresh(&lessons), whole);
1730        assert_eq!(peek(&lessons), whole, "the tail it folded is saved");
1731        // No store at all: the prior, no panic.
1732        assert_eq!(load(Path::new("/nonexistent/borg-summary/lessons.jsonl")), Summary::default());
1733        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
1734    }
1735
1736    #[test]
1737    fn compaction_keeps_the_newest_checks_and_their_joins() {
1738        let text = lines(&log());
1739        let mut s = fold(&text, Summary::default());
1740        let kept = s.checks.len() - 1;
1741        let mut ix = Index::of(&s);
1742        let first = s.checks[1].first as u64;
1743        s.compact(&mut ix, 1, 0);
1744        assert_eq!((s.checks.len(), s.dropped), (kept, first));
1745        assert_eq!(s.checks[0].first, 0);
1746        assert!(s.applies.iter().all(|a| (a.check as usize) < s.checks.len()));
1747        // A later verdict still finds its check.
1748        let more = lines(&[graded("s", "c5", &["Z"], &[2], &[], &["Z"], 60), verdict(outcome::CHANGED, "s", "Z", "c5", "queued", 61)]);
1749        let s = fold(&more, s);
1750        assert_eq!(label_of(&s, "c5", "Z").1, Label::Changed);
1751    }
1752
1753    #[test]
1754    fn every_ended_session_counts_its_deliveries_and_a_resumed_one_moves_them() {
1755        let ended = |session: &str, ms| rec(outcome::ENDED, session, &[], ms);
1756        let log = [
1757            // A stop asks nothing and hands A and B over free; the session ends before a next prompt.
1758            stop("a", "a1", &["A", "B"], &[2, 1], &[], &["A", "B"], 1),
1759            never("a", "a1", &["A", "B"], 2),
1760            ended("a", 3),
1761            // Its one question corrected, a session leaves no settle line: its end alone counts it.
1762            graded("b", "b1", &["C", "D"], &[3, 1], &["C"], &["D"], 10),
1763            delivery("b", "C", "b1", outcome::ROUTE_ASKED, 11),
1764            delivery("b", "D", "b1", outcome::ROUTE_FREE, 11),
1765            rec(outcome::CORRECTED, "b", &["C"], 12),
1766            ended("b", 13),
1767            // An end hook from before `ended`: its `never_delivered` deliveries end the session.
1768            stop("c", "c1", &["E"], &[2], &[], &["E"], 20),
1769            never("c", "c1", &["E"], 21),
1770            // Still open: nothing counts yet.
1771            stop("d", "d1", &["F"], &[2], &[], &["F"], 30),
1772            // A session without a graded check has nothing to count: no entry.
1773            ended("e", 31),
1774        ];
1775        let s = fold(&lines(&log), Summary::default());
1776        let of = |s: &Summary, id: &str| s.sessions.iter().find(|x| x.id == id).cloned().unwrap();
1777        assert_eq!(of(&s, "a").tally[FREE_STOP], (2, 0), "no next prompt came");
1778        assert_eq!((of(&s, "b").tally[FREE_LIVE], of(&s, "b").tally[WAIT]), ((1, 1), (1, 0)), "D delivered; C asked during the turn");
1779        assert_eq!(of(&s, "c").tally[FREE_STOP], (1, 0));
1780        assert_eq!(of(&s, "d").tally, [(0, 0); TALLIES]);
1781        assert_eq!(s.sessions.iter().map(|x| (x.id.as_str(), x.ended)).collect::<Vec<_>>(), [("a", true), ("b", true), ("c", true), ("d", false)]);
1782        // Resumed, `a` hands A over at its next prompt after all: the count follows the latest record.
1783        let resumed = fold(&lines(&[delivery("a", "A", "a1", outcome::ROUTE_FREE, 40)]), s);
1784        assert_eq!(of(&resumed, "a").tally[FREE_STOP], (2, 1));
1785    }
1786
1787    #[test]
1788    fn left_for_later_is_a_live_checks_rate() {
1789        let log = [
1790            // The stop leaves L and M for later and the next turn's live check asks L: not what ρ is,
1791            // a lesson left for later during the turn delivered later.
1792            stop("s", "k1", &["L", "M"], &[1, 1], &[], &[], 1),
1793            graded("s", "k2", &["L"], &[3], &["L"], &[], 5),
1794            delivery("s", "L", "k2", outcome::ROUTE_ASKED, 6),
1795            // A live check leaves N for later, and its turn's stop asks it.
1796            graded("s", "k3", &["N"], &[1], &[], &[], 7),
1797            stop("s", "k4", &["N"], &[3], &["N"], &[], 8),
1798            Outcome { channel: "stop".into(), ..delivery("s", "N", "k4", outcome::ROUTE_ASKED, 9) },
1799            rec(outcome::ENDED, "s", &[], 10),
1800        ];
1801        let s = fold(&lines(&log), Summary::default());
1802        assert_eq!(s.sessions[0].tally[LATER], (1, 1), "k3's N alone");
1803        // The stop's applies still say what came of them.
1804        let k1 = s.checks[0].first as usize;
1805        assert!(s.applies[k1].later && !s.applies[k1 + 1].later, "L arrived later, M never");
1806    }
1807
1808    #[test]
1809    fn a_lesson_its_session_had_counts_in_no_delivery_tally() {
1810        let ended = |session: &str, ms| rec(outcome::ENDED, session, &[], ms);
1811        let log = [
1812            // Delivered early before the session's first check; an older binary then dropped E's free
1813            // hand-over without a record: not a lesson that failed to arrive.
1814            early("a", &["E"], 1),
1815            stop("a", "a1", &["E", "F"], &[2, 1], &[], &["E", "F"], 2),
1816            Outcome { channel: "queued".into(), ..rec(outcome::ASKED, "a", &["F"], 3) },
1817            ended("a", 4),
1818            // Handed over at the session's first check, free again at its next: dropped as given before.
1819            graded("b", "b1", &["G"], &[2], &[], &["G"], 10),
1820            delivery("b", "G", "b1", outcome::ROUTE_FREE, 11),
1821            graded("b", "b2", &["G", "H"], &[2, 1], &[], &["G", "H"], 12),
1822            Outcome { reason: "given before".into(), ..delivery("b", "G", "b2", outcome::ROUTE_DROPPED_DONE, 13) },
1823            delivery("b", "H", "b2", outcome::ROUTE_FREE, 13),
1824            ended("b", 14),
1825            // The hook's state had K, the log no delivery of it (`nofree`): left for later, only M counts.
1826            Outcome { nofree: vec!["K".into()], ..graded("c", "c1", &["K", "M"], &[2, 1], &[], &[], 20) },
1827            ended("c", 21),
1828        ];
1829        let s = fold(&lines(&log), Summary::default());
1830        let of = |id: &str| s.sessions.iter().find(|x| x.id == id).cloned().unwrap();
1831        assert_eq!(of("a").tally[FREE_STOP], (1, 1), "F alone: the session had E");
1832        assert_eq!(of("b").tally[FREE_LIVE], (2, 2), "b1's G and b2's H: at b2 the session had G");
1833        assert_eq!(of("c").tally[LATER], (1, 0), "M alone");
1834        let a1 = s.checks.iter().find(|c| c.id == "a1").unwrap().first as usize;
1835        assert!(s.applies[a1].had && !s.applies[a1 + 1].had);
1836        // What a session was shown before its first check is its own from that check on.
1837        assert!(s.fresh.is_empty());
1838        let names = |x: &Session| x.had.iter().map(|&l| s.lessons[l as usize].clone()).collect::<Vec<_>>();
1839        assert_eq!((names(&of("a")), names(&of("b")), names(&of("c"))), (vec!["E".into(), "F".into()], vec!["G".into(), "H".into()], vec![]));
1840    }
1841
1842    #[test]
1843    fn a_live_question_is_delivered_when_it_reaches_the_drone() {
1844        let at_stop = |o: Outcome| Outcome { channel: "stop".into(), ..o };
1845        let log = [
1846            // Q4 and Q6 were handed over early. Q4, asked again and found done at the stop, counts
1847            // nowhere; Q6, not acted on, reaches the drone and is judged as any question: its verdict
1848            // stays on its apply (top.rs label health), its delivery out of the tallies (`Apply::had`).
1849            early("q", &["Q4", "Q6"], 1),
1850            graded("q", "q1", &["Q1", "Q2", "Q3", "Q4", "Q6"], &[3, 3, 3, 3, 3], &["Q1", "Q2", "Q3", "Q4", "Q6"], &[], 2),
1851            delivery("q", "Q1", "q1", outcome::ROUTE_ASKED, 3),
1852            delivery("q", "Q6", "q1", outcome::ROUTE_ASKED, 3),
1853            verdict(outcome::CHANGED, "q", "Q6", "q1", "live", 4),
1854            // Q2 waited for the stop and was asked in its block; Q3 was not deliverable there.
1855            at_stop(delivery("q", "Q2", "q1", outcome::ROUTE_ASKED, 5)),
1856            Outcome { reason: "not deliverable".into(), ..delivery("q", "Q3", "q1", outcome::ROUTE_DROPPED_DONE, 5) },
1857            Outcome { reason: "done at the stop".into(), ..delivery("q", "Q4", "q1", outcome::ROUTE_DROPPED_DONE, 5) },
1858            // A stop's question blocks: certain, never tallied.
1859            stop("q", "q2", &["Q5"], &[3], &["Q5"], &[], 6),
1860            at_stop(delivery("q", "Q5", "q2", outcome::ROUTE_ASKED, 7)),
1861            rec(outcome::ENDED, "q", &[], 8),
1862        ];
1863        let s = fold(&lines(&log), Summary::default());
1864        assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((3, 2), (3, 1)));
1865        let q6 = s.checks[0].first as usize + 4;
1866        assert_eq!((label_of(&s, "q1", "Q6"), s.applies[q6].had), ((Route::Ask, Label::Changed, Delivered::Yes), true));
1867        // Learned as the free rates are, Beta(1, 1): (1 + 2) / (2 + 3).
1868        assert!((policy::fit(&s, None).rates.ask_live - 0.6).abs() < 1e-12);
1869        // Before a live question is tallied, the fixed rule's certainty.
1870        assert_eq!((policy::fit(&Summary::default(), None).rates.ask_live, policy::prior().rates.ask_live), (1.0, 1.0));
1871        // Resumed, the session asks Q3 after all: both its counts move.
1872        let s = fold(&lines(&[at_stop(delivery("q", "Q3", "q1", outcome::ROUTE_ASKED, 9))]), s);
1873        assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((3, 3), (3, 2)));
1874    }
1875
1876    #[test]
1877    fn a_superseded_ask_counts_nowhere_in_the_live_question_tally() {
1878        // A detached check's ask waits; a newer check asks the same lesson and takes the older line off
1879        // the queue (hook.rs `supersede`); the newer ask reaches the drone. One question, delivered.
1880        let log = [
1881            graded("a", "a1", &["A"], &[3], &["A"], &[], 1),
1882            graded("a", "a2", &["A"], &[3], &["A"], &[], 2),
1883            Outcome { channel: "live".into(), reason: "superseded".into(), ..delivery("a", "A", "a1", outcome::ROUTE_SUPERSEDED, 3) },
1884            Outcome { channel: "live".into(), ..delivery("a", "A", "a2", outcome::ROUTE_ASKED, 4) },
1885            rec(outcome::ENDED, "a", &[], 5),
1886        ];
1887        let s = fold(&lines(&log), Summary::default());
1888        assert_eq!(s.sessions[0].tally[ASK_LIVE], (1, 1), "the superseded ask is not an undelivered question");
1889        assert_eq!(label_of(&s, "a1", "A"), (Route::Ask, Label::Open, Delivered::Superseded));
1890        assert!(!waiting(&s.applies[s.checks[0].first as usize]), "no verdict waits on it");
1891        // Superseded after the session's tallies were taken (it ended, then resumed): its count goes.
1892        let ended = fold(&lines(&[graded("r", "r1", &["A"], &[3], &["A"], &[], 1), rec(outcome::ENDED, "r", &[], 2)]), Summary::default());
1893        assert_eq!(ended.sessions[0].tally[ASK_LIVE], (1, 0));
1894        let resumed = fold(&lines(&[Outcome { channel: "live".into(), ..delivery("r", "A", "r1", outcome::ROUTE_SUPERSEDED, 3) }]), ended);
1895        assert_eq!(resumed.sessions[0].tally[ASK_LIVE], (0, 0));
1896        // A withdrawn ask (settled by later work) stays a dropped one, as before.
1897        let log = [
1898            graded("b", "b1", &["A"], &[3], &["A"], &[], 1),
1899            Outcome { channel: "live".into(), reason: "settled by later work".into(), ..delivery("b", "A", "b1", outcome::ROUTE_DROPPED_DONE, 3) },
1900            rec(outcome::ENDED, "b", &[], 5),
1901        ];
1902        assert_eq!(fold(&lines(&log), Summary::default()).sessions[0].tally[ASK_LIVE], (1, 0));
1903    }
1904
1905    #[test]
1906    fn a_hand_over_the_stop_asked_in_place_is_its_free_applys_delivery_and_verdict() {
1907        // What hook.rs `stop_ask` writes for a live check's question-count test draw: H handed over in
1908        // place of a question, and the stop came before any delivery took it, so it asked H in its block
1909        // with A, the check's waiting question.
1910        let at_stop = |o: Outcome| Outcome { channel: "stop".into(), ..o };
1911        let log = [
1912            graded("s", "k1", &["A", "H"], &[3, 2], &["A"], &["H"], 1),
1913            stop("s", "k2", &[], &[], &[], &[], 2),
1914            at_stop(Outcome { check: "k1".into(), ..rec(outcome::ASKED, "s", &["A"], 3) }),
1915            Outcome { reason: "at the stop".into(), ..at_stop(delivery("s", "A", "k1", outcome::ROUTE_ASKED, 3)) },
1916            at_stop(Outcome { check: "k1".into(), ..rec(outcome::ASKED, "s", &["H"], 3) }),
1917            Outcome { reason: "in place of a hand-over".into(), ..at_stop(delivery("s", "H", "k1", outcome::ROUTE_ASKED, 3)) },
1918            // `#borg-routed H=k1`: the verdict names H's check, on channel stop.
1919            verdict(outcome::CHANGED, "s", "H", "k1", "stop", 4),
1920            verdict(outcome::IGNORED, "s", "A", "k1", "stop", 4),
1921            Outcome { reason: outcome::UNJUDGED_NONE.into(), check: "k1".into(), ..rec(outcome::NOT_CORRECTED, "s", &["A", "H"], 5) },
1922            rec(outcome::ENDED, "s", &[], 6),
1923        ];
1924        let s = fold(&lines(&log), Summary::default());
1925        assert_eq!(label_of(&s, "k1", "H"), (Route::Free, Label::Changed, Delivered::Yes), "the draw's free apply, delivered and judged");
1926        assert_eq!(label_of(&s, "k1", "A"), (Route::Ask, Label::Ignored, Delivered::Yes));
1927        assert_eq!(s.sessions[0].tally[FREE_LIVE], (1, 1), "H arrived");
1928        assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((1, 1), (1, 1)));
1929        // A stop check's own result stays on its questions.
1930        let log = [stop("t", "t1", &["F", "G"], &[3, 2], &["F"], &["G"], 1), verdict(outcome::CHANGED, "t", "G", "t1", "stop", 2)];
1931        assert_eq!(label_of(&fold(&lines(&log), Summary::default()), "t1", "G").1, Label::Open, "a stop's free lesson is judged on channel queued");
1932    }
1933
1934    #[test]
1935    fn a_live_question_of_a_check_without_a_3_is_no_question_that_waits_for_the_stop() {
1936        let log = [
1937            // A check with a 3: its question waits for the stop and is asked there.
1938            graded("t", "t1", &["A"], &[3], &["A"], &[], 1),
1939            Outcome { channel: "stop".into(), ..delivery("t", "A", "t1", outcome::ROUTE_ASKED, 2) },
1940            // Without a 3 (`live-next`): no stop asks B, and the session ends before a tool call.
1941            graded("t", "t2", &["B", "C"], &[2, 1], &["B"], &[], 3),
1942            Outcome { channel: "queued".into(), ..never("t", "t2", &["B"], 4) },
1943            rec(outcome::ENDED, "t", &[], 5),
1944        ];
1945        let s = fold(&lines(&log), Summary::default());
1946        assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((1, 1), (1, 1)), "t1's A alone");
1947        assert_eq!(label_of(&s, "t2", "B"), (Route::Ask, Label::Open, Delivered::Dropped), "its apply still says what came of it");
1948        // Resumed, a tool call asks B after all: no tally moves.
1949        let s = fold(&lines(&[delivery("t", "B", "t2", outcome::ROUTE_ASKED, 6)]), s);
1950        assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((1, 1), (1, 1)));
1951    }
1952
1953    #[test]
1954    fn a_tail_sample_teaches_the_yield_past_the_pool() {
1955        // A pool of four (positions 0 to 3) and a tail of two the budget dropped, at positions 9 and 20 of
1956        // the whole fill order; T1 applied.
1957        let tailed = |pt: f64, at: &[u16]| Outcome {
1958            shown: 4,
1959            tail: vec!["T1".into(), "T2".into()],
1960            pt,
1961            fill: [&[0, 9][..], at].concat(),
1962            ..graded("s", "t1", &["A", "T1"], &[2, 1], &[], &["A", "T1"], 1)
1963        };
1964        let s = fold(&lines(&[tailed(0.25, &[9, 20])]), Summary::default());
1965        // Positions 0, 1–2, 3–6 as the pool showed them; 7–14 and 15–30 each a tail lesson.
1966        assert_eq!(s.pool.bins, [(1, 1), (2, 0), (1, 0), (1, 1), (1, 0)]);
1967        assert_eq!((s.pool.shown, s.pool.tokens), (4, 6000), "the pool's own: the tail is extra");
1968        assert_eq!(s.pool.tail, (2, 1), "the tail's lessons shown and applied, as borg top counts them");
1969        // No chance or one past 1 (no uniform sample), positions missing or inside the pool: the pool alone.
1970        for o in [tailed(0.0, &[9, 20]), tailed(1.5, &[9, 20]), tailed(0.25, &[9]), tailed(0.25, &[2, 20])] {
1971            let s = fold(&lines(&[o]), Summary::default());
1972            assert_eq!((s.pool.bins, s.pool.tail), (vec![(1, 1), (2, 0), (1, 0)], (0, 0)));
1973        }
1974    }
1975
1976    #[test]
1977    fn sessions_shown_lessons_before_a_check_stay_bounded() {
1978        let n = MAX_FRESH + 10;
1979        let text = lines(&(0..n).map(|t| early(&format!("e{t}"), &["L"], t as i64)).collect::<Vec<_>>());
1980        let s = fold(&text, Summary::default());
1981        // The guard: past `MAX_FRESH` the oldest went, the newest stayed.
1982        assert!(s.fresh.len() <= MAX_FRESH, "{} kept", s.fresh.len());
1983        let newest = format!("e{}", n - 1);
1984        assert!(!s.fresh.contains_key("e0") && s.fresh.contains_key(&newest));
1985        assert!(s.sessions.is_empty() && s.consistent());
1986        for at in [text.len() / 2, text.len() - 100] {
1987            let at = text[..at].rfind('\n').unwrap() + 1;
1988            assert_eq!(fold(&text[at..], fold(&text[..at], Summary::default())), s, "split after byte {at}");
1989        }
1990        // The newest graded after all: its free hand-over of what it had counts nowhere.
1991        let s = fold(&lines(&[stop(&newest, "x1", &["L"], &[2], &[], &["L"], 9_999), rec(outcome::ENDED, &newest, &[], 10_000)]), s);
1992        assert_eq!(s.sessions[0].tally[FREE_STOP], (0, 0));
1993    }
1994
1995    #[test]
1996    fn a_regrets_missed_lesson_is_no_correction() {
1997        let log = [
1998            rec(outcome::MISSED, "s", &["A"], 1),
1999            Outcome { cause: "R1".into(), ..rec(outcome::MISSED, "s", &["B"], 2) },
2000            rec(outcome::CORRECTED, "s", &["C"], 3),
2001        ];
2002        assert_eq!(fold(&lines(&log), Summary::default()).signals[0].corrections, 2, "A's and C's: B's is the regret R1 again");
2003    }
2004
2005    #[test]
2006    fn the_monitors_weights_persist_through_save_and_fold() {
2007        let text = lines(&log());
2008        let half = text[..text.len() / 2].rfind('\n').unwrap() + 1;
2009        let lessons = store("monitor", &text[..half]);
2010        let s = Summary { monitor: Monitor { ess: 12.5, max_weight: 3.25, labelled: 7, ..Monitor::default() }, ..load(&lessons) };
2011        save(&lessons, &s).unwrap();
2012        std::fs::write(outcome::path_beside(&lessons), &text).unwrap();
2013        let folded = refresh(&lessons);
2014        assert_eq!(folded.at, text.len() as u64, "the tail folded");
2015        for x in [&folded, &peek(&lessons)] {
2016            assert_eq!((x.monitor.ess, x.monitor.max_weight, x.monitor.labelled), (12.5, 3.25, 7));
2017        }
2018        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
2019    }
2020
2021    /// `n` sessions, each shown a lesson early that no check grades, then one live check handing its
2022    /// own lesson over free (delivered in the even sessions), then its end.
2023    fn many(n: usize) -> String {
2024        let mut log = Vec::new();
2025        for t in 0..n {
2026            let (id, check, lesson, ms) = (format!("s{t}"), format!("c{t}"), format!("L{t}"), 10 * t as i64);
2027            log.push(early(&id, &[&format!("X{t}")], ms));
2028            log.push(graded(&id, &check, &[&lesson], &[2], &[], &[&lesson], ms));
2029            if t % 2 == 0 {
2030                log.push(delivery(&id, &lesson, &check, outcome::ROUTE_FREE, ms + 1));
2031            }
2032            log.push(rec(outcome::ENDED, &id, &[], ms + 2));
2033        }
2034        lines(&log)
2035    }
2036
2037    #[test]
2038    fn the_session_table_stays_bounded_and_keeps_what_its_readers_need() {
2039        let n = MAX_SESSIONS + 2;
2040        let text = many(n);
2041        let s = fold(&text, Summary::default());
2042        // The guard: past `MAX_SESSIONS` the oldest went, an even count, never the newest.
2043        assert!(s.sessions.len() <= MAX_SESSIONS, "{} sessions kept", s.sessions.len());
2044        assert_eq!((n - s.sessions.len()) % 2, 0, "the monitor's halves keep their sessions");
2045        assert_eq!(s.sessions.last().map(|x| x.id.clone()), Some(format!("s{}", n - 1)));
2046        assert!(s.consistent());
2047        // Their checks went with them, and every lesson only they named; a kept session keeps what it
2048        // was shown, each lesson by its name.
2049        assert_eq!((s.checks.len(), s.dropped), (s.sessions.len(), (n - s.sessions.len()) as u64));
2050        assert_eq!(s.lessons.len(), 2 * s.applies.len());
2051        let newest = s.sessions.last().unwrap();
2052        let shown: Vec<&str> = newest.had.iter().map(|&l| s.lessons[l as usize].as_str()).collect();
2053        assert_eq!(shown, [format!("X{}", n - 1)], "an odd session: its free lesson never came");
2054        let even = &s.sessions[s.sessions.len() - 2];
2055        let shown: Vec<&str> = even.had.iter().map(|&l| s.lessons[l as usize].as_str()).collect();
2056        assert_eq!(shown, [format!("X{}", n - 2), format!("L{}", n - 2)]);
2057        // Every age as before: under every half-life the tallies are all n sessions', and so are the
2058        // sessions and checks spend divides by.
2059        for h in policy::HALF_LIVES {
2060            let want = (0..n).fold((0.0, 0.0), |(k, miss), t| {
2061                let w = policy::decay((n - t) as u32, h);
2062                if t % 2 == 0 { (k + w, miss) } else { (k, miss + w) }
2063            });
2064            let got = s.tallies(h)[FREE_LIVE];
2065            let close = |a: f64, b: f64| (a - b).abs() <= 1e-9 * b.max(1.0);
2066            assert!(close(got.0, want.0) && close(got.1, want.1), "{h:?}: {got:?} against {want:?}");
2067        }
2068        assert_eq!(s.checked(), (n as u64, n as u64));
2069        // A fold split before the compaction, or after it, is the whole fold.
2070        for at in [text.len() / 3, text.len() - 200] {
2071            let at = text[..at].rfind('\n').unwrap() + 1;
2072            assert_eq!(fold(&text[at..], fold(&text[..at], Summary::default())), s, "split after byte {at}");
2073        }
2074    }
2075
2076    /// The question-count test's checks (module doc): a drawn count, played or refused, counts by its k
2077    /// and s and marks its check, its control arm apart, its session once; a refusal also counts by its
2078    /// k; a record whose k or s cannot be its own (a refusal without its s among them), one outside the
2079    /// test and one not of the learned policy count nowhere. The
2080    /// counts are all-time: compaction drops the checks, never them.
2081    #[test]
2082    fn the_question_count_tests_checks_count_by_k_and_s_and_mark_their_check() {
2083        let x = |session: &str, check: &str, source: &str, k: Option<u32>, s: Option<u32>, ms| Outcome {
2084            source: source.into(),
2085            xk: k,
2086            xs: s,
2087            ..graded(session, check, &["A", "B", "C"], &[2, 2, 1], &["A"], &["B", "C"], ms)
2088        };
2089        let text = lines(&[
2090            x("x", "e1", policy::EXPERIMENT, Some(3), Some(1), 10),
2091            x("x", "e2", policy::EXPERIMENT, Some(3), Some(1), 20),
2092            x("x", "e3", policy::EXPERIMENT_CONTROL, Some(3), Some(0), 30),
2093            // No s: no source of the test writes it.
2094            x("x", "g1", policy::GATE, Some(3), None, 40),
2095            // Refused after its draw: randomised, the fixed rule played.
2096            x("x", "g2", policy::GATE, Some(3), Some(2), 45),
2097            // s past k, k past the check's applies.
2098            x("x", "bad", policy::EXPERIMENT, Some(3), Some(4), 50),
2099            x("x", "big", policy::EXPERIMENT, Some(9), Some(1), 60),
2100            x("x", "ev", policy::EVIDENCE, None, None, 70),
2101            Outcome { policy: "fixed".into(), ..x("x", "f1", policy::EXPERIMENT, Some(3), Some(2), 80) },
2102            x("y", "y1", policy::EXPERIMENT, Some(2), Some(2), 90),
2103        ]);
2104        let s = fold(&text, Summary::default());
2105        let e = &s.experiment;
2106        assert_eq!(
2107            (e.ran.as_slice(), e.control, e.refused.as_slice(), e.sessions),
2108            (&[(2, 2, 1), (3, 0, 1), (3, 1, 2), (3, 2, 1)][..], 1, &[(3, 1)][..], 2),
2109            "{e:?}"
2110        );
2111        assert_eq!(e.totals(), (5, 1));
2112        let marked: Vec<(&str, Option<u32>, Option<u32>)> = s.checks.iter().map(|c| (c.id.as_str(), c.xk, c.xs)).filter(|c| c.1.is_some()).collect();
2113        assert_eq!(marked, [("e1", Some(3), Some(1)), ("e2", Some(3), Some(1)), ("e3", Some(3), Some(0)), ("g2", Some(3), Some(2)), ("y1", Some(2), Some(2))]);
2114        assert!(s.sessions.iter().all(|x| x.tested));
2115        // The fold every count shares: a prefix, then the rest, is the whole.
2116        let at = text[..text.len() / 2].rfind('\n').unwrap() + 1;
2117        assert_eq!(fold(&text[at..], fold(&text[..at], Summary::default())), s);
2118        // Compaction takes the checks, not the counts.
2119        let mut compacted = s.clone();
2120        let mut ix = Index::of(&compacted);
2121        compacted.compact(&mut ix, s.applies.len(), 0);
2122        assert_eq!((compacted.checks.len(), &compacted.experiment), (0, &s.experiment));
2123    }
2124
2125    #[test]
2126    fn a_learned_decision_the_fixed_rule_took_over_counts_by_its_reason() {
2127        let fell = |check: &str, reason: &str, applied: &[&str], ms| Outcome {
2128            policy: "fixed".into(),
2129            reason: reason.into(),
2130            ..graded("f", check, applied, &vec![1; applied.len()], &[], applied, ms)
2131        };
2132        let text = lines(&[
2133            fell("f1", outcome::DECIDED_BUSY, &["A"], 1),
2134            fell("f2", outcome::DECIDED_BUSY, &["B"], 2),
2135            // No applies: the check still waited for the lock.
2136            fell("f3", outcome::DECIDED_NO_LOCK, &[], 3),
2137            fell("f4", outcome::DECIDED_NO_RECORD, &["A"], 4),
2138            // Decided by the policy in force, and a reason this binary does not know: no fallback.
2139            graded("f", "f5", &["A"], &[1], &[], &["A"], 5),
2140            fell("f6", "something_new", &["A"], 6),
2141        ]);
2142        let s = fold(&text, Summary::default());
2143        assert_eq!(s.fell_back, [2, 1, 1], "lock_busy, lock_failed, unrecorded");
2144        assert_eq!(s.checked().1, 6, "the graded checks a fallback is one of");
2145        // The fold every count shares: a prefix, then the rest, is the whole.
2146        let at = text[..text.len() / 2].rfind('\n').unwrap() + 1;
2147        assert_eq!(fold(&text[at..], fold(&text[..at], Summary::default())), s);
2148        // Kept by the cache, and a cache of the layout before them (protocol: version 5) is rebuilt,
2149        // never resumed without them.
2150        let lessons = store("fell-back", &text);
2151        save(&lessons, &s).unwrap();
2152        assert_eq!(peek(&lessons).fell_back, s.fell_back);
2153        save(&lessons, &Summary { sv: 5, fell_back: Default::default(), ..s.clone() }).unwrap();
2154        assert_eq!(load(&lessons), s);
2155        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
2156    }
2157
2158    #[test]
2159    fn the_prior_writes_the_short_line() {
2160        let prior = Summary::default();
2161        assert_eq!((prior.sv, prior.at, &prior.fit), (SV, 0, &Fit::default()));
2162        let line = serde_json::to_string(&prior).unwrap();
2163        assert_eq!(line, format!(r#"{{"sv":{SV},"at":0,"fit":{{}}}}"#));
2164        assert_eq!(serde_json::from_str::<Summary>(&line).unwrap(), prior);
2165        let none = Path::new("/nonexistent/borg-summary/lessons.jsonl");
2166        assert_eq!(load(none), prior);
2167        assert_eq!(rebuild(none), prior);
2168    }
2169}