Skip to main content

borg/
outcome.rs

1//! What happened after the conscience asked a lesson: the signal for down-weighting lessons
2//! that are generic or do not help, and for trust per engineer × area (trust.rs).
3//!
4//! `$BORG_HOME/outcomes.jsonl` is append-only and deterministic, one record per line:
5//! - `asked`: the conscience asked these lessons at a stop or live, or delivered them as context (early,
6//!   or a graded check's 2s and 1s handed over free; hook, 0 tokens);
7//! - `asked_then_corrected`: after lesson X was asked, the engineer still made a correction in the
8//!   same session that `borg learn` merged into X (same concern): asking did not prevent it;
9//! - `asked_not_corrected`: the session ended, or has been idle for `IDLE_MS`, and no later
10//!   correction was merged into X. The idle rule keeps both kinds coming from every `borg learn`
11//!   run: without it, a session whose `end` hook never ran could only ever add corrected counts.
12//!
13//! A correction that contradicts a lesson asked earlier in the session (`supersedes`) also marks that
14//! lesson `asked_then_corrected`; when the conscience rendered asked lessons as a disagreement (the
15//! `asked` record's `groups`), a correction siding with one of them marks the other sides, not the
16//! one it agreed with (`contradicted`).
17//!
18//! - `asked_then_changed` (hook, no extra call): at a later Stop of the session, the consequence model's
19//!   call (predict.rs) also judged, by meaning, that the drone changed its work on the lesson's point
20//!   and that the change avoids the consequence: a use, the deposit that keeps a lesson active
21//!   (memory.rs). The drone's `<id>: applies|skip` is evidence in that prompt, never a label alone;
22//! - `asked_ignored` (hook): the same judge found the lesson's point came up and the drone changed
23//!   nothing on it.
24//! - `asked_not_arisen` (hook, 0.7.5 on): the same judge found the lesson's point never came up in the
25//!   work: nothing to change, so nothing about the lesson. It settles the delivery (no further try),
26//!   is censored for beliefs, trust, the statistics and the team (`is_result`), and the selection model
27//!   reads it as "nothing in the work" (select/summary.rs `Label::NotArisen`).
28//! - `asked_consequence_remains` (hook, 0.7.5 on): the drone changed its work on the point, but the work
29//!   still leads to the consequence. As an unclear verdict: measured, never a result, pending for another
30//!   try. Before 0.7.5 such a verdict was written as `asked_unclear`, which therefore mixes both there.
31//!
32//! Every judge result (0.7.5 on) carries the judge's short reason per lesson (`reasons`, redacted,
33//! predict.rs `JUDGE_REASON_CHARS`). Local: never shared.
34//! - `vetoed` (`borg no`, veto.rs, 0 tokens): the engineer said no to the session's work; every lesson
35//!   shown in the session before it is credited, the strongest negative result.
36//!
37//! - `regretted` (`borg learn`, 0 tokens): a regret episode (a later fix of a decision, or a review finding;
38//!   learn/regret.rs) generalised into a lesson blames the lessons asked in the deciding session before the
39//!   decision (`regretted`): asking did not prevent it. `credit` carries each lesson's share of the blame;
40//!   memory.rs weighs it below a correction. Deliberately not read by trust.rs (`sessions`), by `weight`
41//!   (the legacy single-number view) or by attention.rs: a regret is about a lesson, not about an
42//!   engineer following it.
43//!
44//! - `tested` (`borg learn`, 0 tokens): the session's own test runs (hook.rs `capture_test`) ended green
45//!   after a lesson was asked as a question (a free delivery is none): a failed run after the ask, then a
46//!   pass, or a pass after the drone changed its work on the lesson's point (`asked_then_changed`); never
47//!   for a question the drone ignored (`asked_then_ignored`). A
48//!   weak credit (memory.rs `TESTED_W`), once per (session, lesson) (`tested`). It is about the work,
49//!   not about an engineer following a lesson: never a use, and not read by trust.rs, `weight` or
50//!   attention.rs.
51//!
52//! An unclear verdict (`asked_unclear`, measured, never a result), a remaining consequence
53//! (`asked_consequence_remains`, the same), or a session whose later turn ends
54//! never reach the model (the prefilter skipped them, or BORG_MATCH=bm25), leaves the lesson pending:
55//! `settle` turns it into `asked_not_corrected` with the reason it had no verdict (`reason`). That is
56//! censored for beliefs (memory.rs): no correction followed, but whether the drone acted on the lesson
57//! is unknown.
58//!
59//! - `delivery` (hook, local): per graded check and lesson, the route delivery actually took (`route`:
60//!   asked, free, placebo, dropped because done or over the character budget, never delivered) and why,
61//!   so what a check decided (`graded`) and what reached the drone never disagree unseen.
62//! - A placebo (hook.rs `#borg-placebo`): an apply the check routed nowhere, judged blindly where a free
63//!   delivery would be. Its results carry channel `placebo` (`PLACEBO`): the judge's floor, no result for
64//!   any reader that learns from results (`is_result`), never shared. One the judge never gave a verdict
65//!   settles as `asked_not_corrected` on that channel with its reason (`settle_with`), as an unjudged ask
66//!   does: the selection summary censors it (select/summary.rs `settled`).
67//!
68//! `asked` and `graded` records carry `p`, each lesson's probability of being asked under the policy in
69//! force at that check (its propensity), for off-policy evaluation of another policy from this log. An
70//! `asked` record from a Thompson slot draw (the bm25 and non-graded paths) also carries that draw's
71//! pool (`pool`, `base`, `scoped`): every competitor with its score before the usefulness factor, so
72//! `borg debug ope` (ope.rs) can recompute the propensity under other belief weights. Local only: the team
73//! projection drops them.
74//!
75//! **Team outcomes.** The raw log stays on this machine (it holds session ids and repos). `publish`
76//! appends a redacted, deterministic projection of the results the team may see (`SHARED_KINDS`) to
77//! `outcomes-team.jsonl` (`TEAM_FILE`), which the mesh syncs (borg-mesh lines.rs `SYNCED`): kind,
78//! lesson ids, time, propensity, channel, the hashed session and repo (`tag`), a regret's hashed cause
79//! and credit, and the node, never raw sessions, repos, causes, groups, grades or text. Only the belief readers opt in to the team view
80//! (`read_team`): memory and trust in the conscience (conscience.rs `load_as`, folded incrementally by
81//! facts.rs into the same view), `borg trust`, `borg memory` and borg-web's lessons. learn.rs (it would
82//! settle remote sessions), attention.rs (labels and
83//! the explore counter are this node's policy), stats.rs (rollups sync their own counts), install.rs and
84//! the hook's pending/judge read this machine's log only. BORG_SHARE_OUTCOMES=off publishes and reads
85//! nothing of the team's.
86//!
87//! A session counts once per lesson. If a resumed session gets both results, corrected wins.
88//! memory.rs derives activation and the usefulness belief from these results; `weight` below is the
89//! earlier, single-number view (kept for reporting and for comparison in the R1 replay).
90
91use std::collections::HashMap;
92use std::path::Path;
93
94use serde::{Deserialize, Serialize};
95
96pub const ASKED: &str = "asked";
97pub const CORRECTED: &str = "asked_then_corrected";
98pub const NOT_CORRECTED: &str = "asked_not_corrected";
99pub const CHANGED: &str = "asked_then_changed";
100pub const IGNORED: &str = "asked_ignored";
101/// One graded check: every lesson the model graded as applying (`lessons`, in its order, with
102/// `grades` and `ranks`), and which of them the check chose to ask (`chosen`). The denominator for
103/// learning how many to ask: a lesson that applied but was not chosen can still turn out needed.
104pub const GRADED: &str = "graded";
105/// A lesson a graded check of the session found applying but did not ask, which a later correction
106/// in that session then merged into or superseded: a question that was needed and not asked (`missed`).
107pub const MISSED: &str = "applied_then_corrected";
108/// The engineer vetoed the session's work (`borg no`, veto.rs): every lesson shown in the session
109/// before it is credited. The strongest negative result.
110pub const VETOED: &str = "vetoed";
111/// A lesson asked in a session before a decision that a later fix (or a review finding) showed was
112/// wrong (learn.rs `credit_regrets`): asking did not prevent it. `cause` names the regret episode and
113/// `credit` carries the share of the blame, (0, 1]. Weaker than a correction (memory.rs `REGRET`).
114pub const REGRETTED: &str = "regretted";
115
116/// The session's own tests went green after the lesson was asked (`tested`): red then green, or green
117/// after a change on the lesson's point. Once per (session, lesson), dated at that pass.
118pub const TESTED: &str = "tested";
119
120/// The judge saw the lesson's delivery but could not tell (an `unclear` verdict): the lesson stays
121/// pending for another try. A label-health measurement only: no belief, trust, statistic, team line or
122/// off-policy decision reads it (`counts`). Before 0.7.5 a change that still leads to the consequence
123/// was written as this kind too (now `REMAINS`), so an older record means either.
124pub const UNCLEAR: &str = "asked_unclear";
125
126/// The judge found the lesson's point never came up in the work since its delivery (`not_arisen`,
127/// predict.rs `Verdict::NotArisen`): a verdict that settles the delivery (`settles`), but no evidence
128/// about the lesson's usefulness, so no result (`is_result`) for beliefs, trust, the statistics, the
129/// team file or the off-policy log. The selection summary reads it as "nothing in the work": no change
130/// on the lesson's point (select/summary.rs `Label::NotArisen`).
131pub const NOT_ARISEN: &str = "asked_not_arisen";
132
133/// The judge found the drone changed its work on the lesson's point but the work still leads to the
134/// consequence (`consequence_remains`): `UNCLEAR`'s semantics (pending for another try, a measurement
135/// only, no result), kept apart so the pure unclear share is measured.
136pub const REMAINS: &str = "asked_consequence_remains";
137
138/// A judge result's `at` when the session-end judge gave it (hook.rs `judge_session`, at `borg learn
139/// --session`): with the whole session's work in view, after every check of the session. Protocol: empty
140/// for a check's verdict (a stop or a live check), so its records stay as before.
141pub const AT_END: &str = "end";
142
143/// Whether a judge result of `kind` settles its delivery: a use (`CHANGED`), an ignore (`IGNORED`) or
144/// nothing to judge (`NOT_ARISEN`). An unclear verdict and a remaining consequence leave it pending.
145#[must_use]
146pub fn settles(kind: &str) -> bool {
147    matches!(kind, CHANGED | IGNORED | NOT_ARISEN)
148}
149
150/// What delivery did with a graded check's decision, per check and lesson (hook.rs): `route` (one of
151/// `ROUTES`), `reason`, `check` the check that decided it. The latest record per (check, lesson) is
152/// the route it took (a session that ends with a lesson queued gets `never_delivered`; resumed, its
153/// later delivery supersedes that). Local and never a result: the denominator of what each route led
154/// to, read by the selection replay (select.rs), not by beliefs.
155pub const DELIVERY: &str = "delivery";
156
157/// The session's end hook ran (hook.rs `on_end`), after its `never_delivered` records: one per end, a
158/// resumed session's again at its next. Local and never a result: where the selection summary counts a
159/// session's delivery tallies (select/summary.rs), whatever else the session logged, so a session with
160/// nothing queued and no ask left open counts too.
161pub const ENDED: &str = "ended";
162
163/// The channel of a placebo's judge results (`asked_then_changed`, `asked_ignored`, `asked_unclear`, and
164/// from 0.7.5 `asked_not_arisen`, `asked_consequence_remains`): a
165/// graded apply never delivered, judged blindly where a free delivery would be (hook.rs `#borg-placebo`).
166/// Its "changed" rate is the judge's own false positives plus what the drone would have done anyway, so
167/// it is never a result about the lesson (`is_result`).
168pub const PLACEBO: &str = "placebo";
169
170/// Whether `o` is evidence about its lessons for the readers that learn from results (memory.rs, trust.rs,
171/// the statistics, the team file): not a placebo's result (`PLACEBO`), not an unclear verdict
172/// (`UNCLEAR`), not a change that leaves the consequence (`REMAINS`) and not a point that never came up
173/// (`NOT_ARISEN`, censored).
174#[must_use]
175pub fn is_result(o: &Outcome) -> bool {
176    o.channel != PLACEBO && !matches!(o.kind.as_str(), UNCLEAR | REMAINS | NOT_ARISEN)
177}
178
179/// Why a lesson settled as `asked_not_corrected` had no verdict (`reason`, `settle_why`). Protocol: the
180/// judge gave one (`UNJUDGED_NONE`, the settle only says no correction followed); its tries ran out on
181/// unclear or missing verdicts (hook.rs `MAX_JUDGE_TRIES`); a fuller judge call left it out
182/// (hook.rs `MAX_PENDING`); its last judge call failed; no model call came after its delivery; or (a
183/// placebo) a later delivery of the lesson in the session took its place in the judge's queue
184/// (`UNJUDGED_LATER`, hook.rs `pending`).
185pub const UNJUDGED_NONE: &str = "judged";
186pub const UNJUDGED_TRIES: &str = "tries";
187pub const UNJUDGED_SLOTS: &str = "slots";
188pub const UNJUDGED_FAILED: &str = "failed";
189pub const UNJUDGED_NO_CALL: &str = "no_call";
190pub const UNJUDGED_LATER: &str = "later";
191
192/// Why a `graded` record's check took the fixed rule instead of the learned policy in force (`reason`;
193/// empty when it decided by the policy in force, hook.rs `decide`). Protocol: another check held the
194/// store's decision lock past hook.rs `DECIDE_WAIT`, the lock could not be taken at all, or the learned
195/// decision's record would not or did not land in the log its gate is folded from (learning off, a
196/// `BORG_CONSCIENCE` outside the store the hook records to, a failed append), so its gate would never be
197/// debited for it. Only the learned policy reads the gate and takes the lock; such a record carries
198/// `policy` fixed and is replayed as the fixed rule.
199pub const DECIDED_BUSY: &str = "lock_busy";
200pub const DECIDED_NO_LOCK: &str = "lock_failed";
201pub const DECIDED_NO_RECORD: &str = "unrecorded";
202
203/// What one correction adds against a lesson, in units of an ignore: a correction of the same concern
204/// right after asking is strong evidence the question did not help, a session without one weak evidence
205/// (the engineer may not have looked). Baseline: design-chosen (no evaluation behind the 2), used by
206/// `weight` and memory.rs `Memory::beta_with` alike; `borg debug ope` (ope.rs) estimates other belief
207/// weights offline, never applies them. Fixed by dated decision (2026-10-31): 1–2 corrections after an
208/// ask per store, and `borg debug ope` finds 0 usable decisions.
209pub const CORRECTED_W: u32 = 2;
210
211/// A session without asks or episodes for this long is treated as ended (24h). Guard: a session whose
212/// `end` hook never ran would otherwise never settle; its state file must be as quiet (learn.rs).
213pub const IDLE_MS: i64 = crate::clock::DAY_MS;
214
215#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
216pub struct Outcome {
217    pub kind: String,
218    pub session: String,
219    pub lessons: Vec<String>,
220    pub ms: i64,
221    pub repo: String,
222    /// `asked` only: the asked lessons rendered as one disagreement (conscience.rs `disagreements`).
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub groups: Vec<Vec<String>>,
225    /// How the lessons were asked (`asked` and the judge's results): `stop` (a turn-end block), `live`
226    /// (a live check during the turn, before 0.7.6), `early` (early delivery, conscience.rs `early`),
227    /// `queued` (a graded check's 2s and 1s, handed over free) or `placebo` (`PLACEBO`: never shown,
228    /// judged blindly where a free delivery would be). Empty in records from before delivery modes.
229    #[serde(default, skip_serializing_if = "String::is_empty")]
230    pub channel: String,
231    /// `graded` and `asked`: each lesson's grade (3 certain, 2 likely, 1 worth a thought) and its rank in
232    /// the model's applies (0 first), aligned with `lessons`, and how many lessons the check graded as
233    /// applying; on `asked`, 0 where no check of the session graded the lesson. For learning how many to
234    /// ask (attention.rs reads them from `graded`).
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub grades: Vec<u8>,
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    pub ranks: Vec<u16>,
239    #[serde(default, skip_serializing_if = "is_zero")]
240    pub applied: u16,
241    /// `graded` only: the lessons the check chose to ask.
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub chosen: Vec<String>,
244    /// `graded` only: the check explored, asking every apply up to `attention::RAIL` whatever the cut,
245    /// so its outcomes label the cut (attention.rs).
246    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
247    pub explore: bool,
248    /// `regretted`: the regret episode (`R…`/`V…` id). The idempotence key with the lesson.
249    #[serde(default, skip_serializing_if = "String::is_empty")]
250    pub cause: String,
251    /// `regretted` only: the lesson's share of the regret, (0, 1].
252    #[serde(default, skip_serializing_if = "is_zero_f")]
253    pub credit: f64,
254    /// `asked` and `graded`: per lesson (aligned with `lessons`), the probability that the policy in force
255    /// at that check asked it: 1 or 0 under the fixed graded rule, the explore mixture under the learned
256    /// cut (attention.rs `propensities`), the share of Thompson draws that give it a slot in
257    /// `budget`/`widen` (conscience.rs `propensity`), the share that delivers it early (conscience.rs
258    /// `early_propensity`), 1 for a free hand-over of a graded check's 2s and 1s. Rounded to 4 decimals
259    /// (`round_p`). Empty in records from before propensities and when unknown (BORG_ONLY, a state file
260    /// from an older binary). For off-policy evaluation (ope.rs).
261    #[serde(default, skip_serializing_if = "Vec::is_empty")]
262    pub p: Vec<f64>,
263    /// `asked` from a Thompson slot draw only (conscience.rs `budget_pool`/`widen_pool`, hook.rs
264    /// `#borg-pool`): the pool the check drew from, in ranking order, the asked lessons included; each
265    /// one's score without its usefulness factor (conscience.rs `pool_base`, unrounded so a replay is
266    /// exact); and whether it is scoped. Aligned with each other, not with `lessons`. Empty for graded
267    /// and live asks and free deliveries. Local: never shared (`share`). For `borg debug ope` (ope.rs).
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub pool: Vec<String>,
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    pub base: Vec<f64>,
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub scoped: Vec<bool>,
274    // The selection fields (select.rs). Every one is left out when empty, so a record without them
275    // writes the line an older binary wrote, and an older line reads with them empty. Local: `share`
276    // copies none of them.
277    /// The graded check (select.rs `check_id`): on `graded`, and on every record of a lesson that check
278    /// routed (its delivery, the judge's result, a placebo's), so a result joins the decision that made
279    /// it. Empty in records from before checks had ids.
280    #[serde(default, skip_serializing_if = "String::is_empty")]
281    pub check: String,
282    /// `graded`: the selection policy in force (select.rs `Policy::name`: fixed, cap, attention,
283    /// learned). Empty in records from before select.rs, which read as the fixed rule (`policy_of`).
284    #[serde(default, skip_serializing_if = "String::is_empty")]
285    pub policy: String,
286    /// `graded`: the fit the policy decided with (select/policy.rs `Fit::key`); empty for the prior.
287    #[serde(default, skip_serializing_if = "String::is_empty")]
288    pub fit: String,
289    /// `graded`: the selection summary the decision read (select/summary.rs `Summary::key`: the bytes
290    /// folded and their hash), and what set its routes (select.rs `Log::source`). A learned check reads
291    /// the summary again under the store's decision lock after its model call (hook.rs `decide`), so
292    /// every learned decision recorded before it is in it; a check by a policy that reads no gate, and
293    /// one that fell back to the fixed rule (`reason`), names the summary it read at its start, which
294    /// records of checks that decided meanwhile may follow. Records other than decisions (a delivery, a
295    /// verdict) may still land between that read and this record; `borg debug select` P1 replays from
296    /// the logged state.
297    /// Empty in older records and for a failed call.
298    #[serde(default, skip_serializing_if = "String::is_empty")]
299    pub seen: String,
300    #[serde(default, skip_serializing_if = "String::is_empty")]
301    pub source: String,
302    /// `graded`: the lessons the check handed over free, in delivery order (`free_of` for older records).
303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
304    pub free: Vec<String>,
305    /// `graded`: per lesson (aligned with `lessons`), its probability of being handed over free under
306    /// the policy in force, as `p` is of being asked; rounded like `p`. Empty in older records, where
307    /// a free lesson's was 1 under the fixed rule.
308    #[serde(default, skip_serializing_if = "Vec::is_empty")]
309    pub pf: Vec<f64>,
310    /// `graded`: the applies the check did not deliver and drew for the placebo arm (judged blindly
311    /// where a free delivery would be, never shown), and each undelivered apply's chance of that draw.
312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
313    pub placebo: Vec<String>,
314    #[serde(default, skip_serializing_if = "is_default")]
315    pub psi: f64,
316    /// `graded`: the graded applies that could not go free at this check because the session already
317    /// had them delivered (select.rs `Ctx::had`, conscience.rs `graded_without`), in the applies' order:
318    /// the applies among the session's deliveries when the check decided, and among the lessons waiting
319    /// in its queue to be asked or handed over by another check (hook.rs `decide`, `waiting_in`), so the
320    /// record alone holds every `had` that bears on its decision.
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub nofree: Vec<String>,
323    /// `graded`, a check of the randomised question-count test (select/policy.rs `Decision::xk`,
324    /// sources `experiment`, `experiment_control` and `gate`): `xk` the fixed rule's delivered non-3
325    /// lessons that may go free (k), `xs` how many of them it asked (s, drawn uniformly in 0..=k; 0 is
326    /// logged). None on every other record, so older lines and checks outside the test write back
327    /// unchanged. Local like the rest of a graded record (`share`).
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub xk: Option<u32>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub xs: Option<u32>,
332    /// `graded`: the collective's tolerance the check was decided under (select.rs `Log::alpha`, its
333    /// `risk`), so a replay decides under it whatever the config says then; None at the default
334    /// (select/policy.rs `ALPHA`), so older lines and default collectives write back unchanged.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub alpha: Option<f64>,
337    /// `graded`: the lessons the pool budget dropped from the model call that a tail sample showed
338    /// anyway (whole.rs `tail`, under every policy), and each one's chance of being sampled (one chance
339    /// for all of the check's tail: k drawn uniformly of the `eligible` − `shown` dropped, unrounded).
340    /// Not in `shown` or `tokens`; each has its place past the budget's in `fill`, after the applies'
341    /// (and, if it applies, among them too), so every place from `shown` to `eligible` − 1 is one
342    /// dropped lesson, shown with chance `pt`.
343    #[serde(default, skip_serializing_if = "Vec::is_empty")]
344    pub tail: Vec<String>,
345    #[serde(default, skip_serializing_if = "is_default")]
346    pub pt: f64,
347    /// An early delivery's record: per lesson (aligned with `lessons`), its score as a share of the best
348    /// on-topic match, done or not (conscience.rs `early_pool`, the share `RELATIVE` bars; hook.rs
349    /// `early_rel`), rounded as `p` is, for a learned early rule. A standing lesson has one too, though
350    /// no score gates it; empty when no plain lesson was on topic to scale by.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub rel: Vec<f64>,
353    /// A delivery's actual route for every lesson of the record (one of `ROUTES`), and why: a drop's
354    /// cause, or why a delivered lesson stayed unjudged (no later call, the judge's slots, its tries);
355    /// on `graded`, why its check fell back to the fixed rule (`DECIDED_BUSY`, `DECIDED_NO_LOCK`,
356    /// `DECIDED_NO_RECORD`).
357    #[serde(default, skip_serializing_if = "String::is_empty")]
358    pub route: String,
359    #[serde(default, skip_serializing_if = "String::is_empty")]
360    pub reason: String,
361    /// `graded`, the pool the model graded: lessons eligible for it, lessons its budget showed, their
362    /// size in tokens (the tail sample beside them in `tail`), and per lesson (aligned with `lessons`)
363    /// its fill position among the shown (0 first), so the yield of each position can be learned from
364    /// every check, a check without applies included; then, past the applies', one position per tail
365    /// lesson (aligned with `tail`), so the yield past the budget is learned from the lessons a tail
366    /// showed that did not apply too (select/summary.rs `tail_of`). A tail lesson's position goes on
367    /// past the budget's: `shown` + its place among the lessons the budget dropped, in the order the
368    /// fill met them (whole.rs `Filled::dropped`), so it is never below `shown`.
369    #[serde(default, skip_serializing_if = "is_default")]
370    pub eligible: u32,
371    #[serde(default, skip_serializing_if = "is_default")]
372    pub shown: u32,
373    #[serde(default, skip_serializing_if = "is_default")]
374    pub tokens: u32,
375    /// `graded`, the pool's token budget its check filled to (hook.rs `check`: `BORG_STOP_POOL_TOKENS`,
376    /// else select.rs `pool_tokens`), so a check at a budget other than the baseline shows. 0: not
377    /// logged (a record from before 0.7.3, or a check without the whole pool).
378    #[serde(default, skip_serializing_if = "is_default")]
379    pub budget: u32,
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub fill: Vec<u16>,
382    /// `graded`, the model call: the model (empty: the CLI's default), a hash of the prompt template,
383    /// the harness, borg's version, the call's duration, and whether it failed (the check then asked
384    /// nothing: fail open); then its cost (`usd`).
385    #[serde(default, skip_serializing_if = "String::is_empty")]
386    pub model: String,
387    #[serde(default, skip_serializing_if = "String::is_empty")]
388    pub prompt_hash: String,
389    #[serde(default, skip_serializing_if = "String::is_empty")]
390    pub harness: String,
391    #[serde(default, skip_serializing_if = "String::is_empty")]
392    pub version: String,
393    #[serde(default, skip_serializing_if = "is_default")]
394    pub dur_ms: u64,
395    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
396    pub failed: bool,
397    /// `graded`: the call's cost in dollars as the harness's CLI reported it (0: it reported none).
398    /// Measured and shown (`borg debug select` P3), never a limit.
399    #[serde(default, skip_serializing_if = "is_default")]
400    pub usd: f64,
401    /// The judge's results (`asked_then_changed`, `asked_ignored`): the judge template's version, 0 for
402    /// the template before versions were logged; and whether the judge saw the question without the
403    /// drone's `<id>: applies|skip` declaration (a random half are, to measure what it adds).
404    #[serde(default, skip_serializing_if = "is_default")]
405    pub judge: u8,
406    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
407    pub stripped: bool,
408    /// The judge's results (0.7.5 on): per lesson (aligned with `lessons`), the judge's short reason for
409    /// its verdict, redacted and at most predict.rs `JUDGE_REASON_CHARS`; empty where it gave none.
410    /// Empty in older records. Local: never shared (`share`), never read as a result.
411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
412    pub reasons: Vec<String>,
413    /// The judge's results (0.7.5 on): where the verdict was given, `AT_END` at the session-end judge
414    /// (hook.rs `judge_session`); empty at a check (a stop or a live check) and in older records. Local.
415    #[serde(default, skip_serializing_if = "String::is_empty")]
416    pub at: String,
417    /// `graded` (0.7.7 on): the model's design moves of the work it checked (predict.rs
418    /// `Prediction::moves`), and per apply (aligned with `lessons`) its one-line why; empty in older
419    /// records and on a failed call. Local, as the whole record is (`share`).
420    #[serde(default, skip_serializing_if = "Vec::is_empty")]
421    pub moves: Vec<String>,
422    #[serde(default, skip_serializing_if = "Vec::is_empty")]
423    pub whys: Vec<String>,
424    /// `graded` (0.7.7 on): the tokens the call showed past `tokens`, the boundary draw's and the tail
425    /// sample's (`tail`), so a prompt's whole lesson size is `tokens` + `xtokens`; 0 without any, and in
426    /// older records. Logged only: the latency fit reads `tokens`, the size a budget is predicted at.
427    #[serde(default, skip_serializing_if = "is_default")]
428    pub xtokens: u32,
429    /// `graded` (0.7.7 on): the lesson line format of the compact prompt (whole.rs `Line`, `a1` or
430    /// `a2`); empty without it (`BORG_STOP_FORMAT=classic`, the shortlist) and in older records.
431    #[serde(default, skip_serializing_if = "String::is_empty")]
432    pub line: String,
433    /// `graded` (0.7.7 on): how its check ran, `async` for a detached check (check_async.rs); empty for
434    /// a stop's own check and in older records.
435    #[serde(default, skip_serializing_if = "String::is_empty")]
436    pub run: String,
437}
438
439/// `route` values. Protocol: what a check decided for a lesson, then what delivery did with it.
440/// Asked, handed over free, or judged as a placebo, as decided; or dropped at delivery because the
441/// session had the lesson already (`ROUTE_DROPPED_DONE`) or the delivery budget was spent
442/// (`ROUTE_DROPPED_CHARS`); or never delivered because the session ended first (`ROUTE_NEVER`); or (0.7.7
443/// on) a detached check's waiting ask taken off the queue because a newer check asks the same lesson
444/// (`ROUTE_SUPERSEDED`): the newer ask carries its delivery.
445pub const ROUTE_ASKED: &str = "asked";
446pub const ROUTE_FREE: &str = "free";
447pub const ROUTE_PLACEBO: &str = "placebo";
448pub const ROUTE_DROPPED_DONE: &str = "dropped_done";
449pub const ROUTE_DROPPED_CHARS: &str = "dropped_chars";
450pub const ROUTE_NEVER: &str = "never_delivered";
451pub const ROUTE_SUPERSEDED: &str = "superseded";
452pub const ROUTES: [&str; 7] = [ROUTE_ASKED, ROUTE_FREE, ROUTE_PLACEBO, ROUTE_DROPPED_DONE, ROUTE_DROPPED_CHARS, ROUTE_NEVER, ROUTE_SUPERSEDED];
453
454/// The policy a `graded` record was decided under: its `policy`; for a record from before select.rs,
455/// the fixed rule (`fixed`), unless it shows the one other policy an older binary logged,
456/// `BORG_STOP_ATTENTION=on`'s learned cut (`attention`): it explored, or a propensity lies strictly
457/// between 0 and 1 (attention.rs `propensities`). A learned cut that asked every apply up to its rail
458/// logged only 0s and 1s, and an evaluation's `BORG_STOP_ASK_CAP` logged nothing of its cap: both read
459/// as the fixed rule.
460#[must_use]
461pub fn policy_of(o: &Outcome) -> &str {
462    match o.policy.as_str() {
463        "" if o.explore || o.p.iter().any(|p| *p > 0.0 && *p < 1.0) => "attention",
464        "" => "fixed",
465        p => p,
466    }
467}
468
469/// The lessons a `graded` record handed over free: its `free`; for a record from before select.rs
470/// (`policy` empty), its policy's rule (`policy_of`) replayed on its grades: the fixed rule's 2s, then
471/// 1s, at most `conscience::CAP`, when no lesson is a 3 (conscience.rs `graded_capped`), else none; the
472/// learned cut's lessons after the ones it asked, at most `conscience::CAP` (attention.rs `split`).
473#[must_use]
474pub fn free_of(o: &Outcome) -> Vec<String> {
475    use crate::conscience::{self, Challenge};
476    if !o.policy.is_empty() || o.grades.len() != o.lessons.len() {
477        return o.free.clone();
478    }
479    if o.grades.contains(&3) {
480        return match policy_of(o) {
481            "attention" => o.lessons.iter().filter(|l| !o.chosen.contains(l)).take(conscience::CAP).cloned().collect(),
482            _ => Vec::new(),
483        };
484    }
485    let ls: Vec<Challenge> = o.lessons.iter().map(|id| Challenge { id: id.clone(), ..Default::default() }).collect();
486    let applied: Vec<(&Challenge, u8)> = ls.iter().zip(o.grades.iter().copied()).collect();
487    conscience::graded_capped(&applied, None).1.into_iter().map(|c| c.id.clone()).collect()
488}
489
490fn is_default<T: Default + PartialEq>(v: &T) -> bool {
491    *v == T::default()
492}
493
494/// A propensity rounded to 4 decimals, so lines stay deterministic and short.
495pub fn round_p(p: f64) -> f64 {
496    (p * 1e4).round() / 1e4
497}
498
499fn is_zero(v: &u16) -> bool {
500    *v == 0
501}
502
503fn is_zero_f(v: &f64) -> bool {
504    *v == 0.0
505}
506
507/// The outcomes store next to a lessons file.
508pub fn path_beside(lessons: &Path) -> std::path::PathBuf {
509    lessons.with_file_name("outcomes.jsonl")
510}
511
512/// The redacted outcomes the team sees, at the store root beside `outcomes.jsonl` (module doc).
513pub const TEAM_FILE: &str = "outcomes-team.jsonl";
514/// Beside `TEAM_FILE`, never synced: the length of `outcomes.jsonl` at the last `publish`.
515pub const PUBLISHED: &str = "outcomes-team.published";
516
517/// The kinds `publish` shares. A whitelist: `graded` and any later kind stay local until deliberately
518/// added here.
519///
520/// Closed under memory.rs `derive`: every kind that changes a lesson's result for a session, or its
521/// belief, travels, so a peer folds the same memory. `vetoed` outranks the session's `changed` (shared
522/// alone, a lesson vetoed here would gain a use and spread there); `regretted` carries its `credit` and
523/// its hashed `cause` (`Shared::c`), the key derive dedupes regrets by. `graded` is the asking policy's
524/// denominator, not a result, and never moves the activity clock (`memory::Clock` counts shared kinds).
525///
526/// Unguarded: any member can write a line under any session tag (tags are public in this file), so a
527/// forged `asked_then_corrected` or `vetoed` overrides a teammate's session on every node but that
528/// teammate's, and a few forged `asked_then_changed` with fresh tags and two repo tags consolidate a
529/// lesson. `node` is self-declared. The mesh admits only members' signed lines (borg-mesh origin.rs),
530/// so this is the trusted-member model; the deferred guards (a session tag bound to its first signer,
531/// consolidation from at least two signers) would close it.
532///
533/// `tested` is shared like `asked_then_changed`, the result it is weaker than: memory folds it, so a peer
534/// without it would hold another belief. Both are this node's own reading of its session (the judge's,
535/// the test runner's output), so the trusted-member model above is the same;
536/// `team_view` counts a peer's `tested` line only for lessons the team's records show its session was
537/// given. It never moves the activity clock either.
538pub const SHARED_KINDS: [&str; 9] = [ASKED, CHANGED, IGNORED, CORRECTED, NOT_CORRECTED, MISSED, VETOED, REGRETTED, TESTED];
539
540/// A session or repo as the team sees it: the first 32 hex digits of blake3("borg-outcome-v1\0" + s).
541/// Unsalted on purpose: the same session hashes to the same tag on every node, so a node can drop its
542/// own published copies (`read_team`).
543pub fn tag(s: &str) -> String {
544    let mut h = blake3::Hasher::new();
545    h.update(b"borg-outcome-v1\0");
546    h.update(s.as_bytes());
547    h.finalize().to_hex()[..32].to_string()
548}
549
550/// `tag`, keeping an empty repo empty.
551fn tag_repo(repo: &str) -> String {
552    if repo.is_empty() { String::new() } else { tag(repo) }
553}
554
555/// One line of `TEAM_FILE`. Never carries `id` and `version` together (the mesh would settle it as a
556/// versioned record).
557#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
558pub struct Shared {
559    pub kind: String,
560    /// `tag` of the session.
561    pub s: String,
562    pub lessons: Vec<String>,
563    pub ms: i64,
564    /// The publishing node (stats.rs `node`), self-declared: dedup does not depend on it.
565    pub node: String,
566    #[serde(default, skip_serializing_if = "Vec::is_empty")]
567    pub p: Vec<f64>,
568    #[serde(default, skip_serializing_if = "String::is_empty")]
569    pub channel: String,
570    /// `tag` of the repo: memory.rs counts distinct repos to consolidate a lesson.
571    #[serde(default, skip_serializing_if = "String::is_empty")]
572    pub r: String,
573    /// `tag` of a `regretted` record's cause (the regret episode): memory.rs counts each regret once.
574    #[serde(default, skip_serializing_if = "String::is_empty")]
575    pub c: String,
576    /// A `regretted` record's share of the blame.
577    #[serde(default, skip_serializing_if = "is_zero_f")]
578    pub credit: f64,
579}
580
581/// `o` as the team sees it, or None for a kind not in `SHARED_KINDS` and for a placebo's result
582/// (`is_result`): the judge's floor on this node, never a result a peer folds.
583pub fn share(o: &Outcome, node: &str) -> Option<Shared> {
584    (SHARED_KINDS.contains(&o.kind.as_str()) && is_result(o)).then(|| Shared {
585        kind: o.kind.clone(),
586        s: tag(&o.session),
587        lessons: o.lessons.clone(),
588        ms: o.ms,
589        node: node.into(),
590        p: o.p.clone(),
591        channel: o.channel.clone(),
592        r: tag_repo(&o.repo),
593        c: tag_repo(&o.cause),
594        credit: o.credit,
595    })
596}
597
598/// Team outcomes on: `BORG_SHARE_OUTCOMES` is not `off`.
599pub fn on() -> bool {
600    std::env::var("BORG_SHARE_OUTCOMES").as_deref() != Ok("off")
601}
602
603/// Appends to `TEAM_FILE` in `home` the shared lines of `home`'s `outcomes.jsonl` it lacks; returns
604/// how many. The lines are deterministic, so a re-run appends nothing; two runs at once may append the
605/// same line twice, which the mesh's union and the per-session count make harmless. Nothing when off.
606/// A local log no longer than at the last publish (`PUBLISHED`, its length then) is not read again: the
607/// log only grows, so nothing new can be in it.
608pub fn publish(home: &Path) -> usize {
609    let local = home.join("outcomes.jsonl");
610    let team = home.join(TEAM_FILE);
611    let mark = home.join(PUBLISHED);
612    let len = std::fs::metadata(&local).map_or(0, |m| m.len());
613    if !on() || len == 0 {
614        return 0;
615    }
616    let published = std::fs::read_to_string(&mark).ok().and_then(|t| t.trim().parse::<u64>().ok());
617    if team.exists() && published == Some(len) {
618        return 0;
619    }
620    let mine = std::fs::read_to_string(&local).unwrap_or_default();
621    let node = crate::stats::node(home);
622    let text = std::fs::read_to_string(&team).unwrap_or_default();
623    let mut have: std::collections::HashSet<String> = text.lines().map(str::to_string).collect();
624    let mut out = String::new();
625    // This engineer's personal lessons (reach.rs) are no team's business, not even by id.
626    let personal = crate::reach::personal_ids(home);
627    let records = mine.lines().filter_map(|l| serde_json::from_str::<Outcome>(l).ok()).filter_map(|mut o| {
628        let named = !o.lessons.is_empty();
629        o.lessons.retain(|id| !personal.contains(id));
630        (!named || !o.lessons.is_empty()).then_some(o)
631    });
632    for line in records.filter_map(|o| share(&o, &node)).filter_map(|x| serde_json::to_string(&x).ok()) {
633        if have.insert(line.clone()) {
634            out.push_str(&line);
635            out.push('\n');
636        }
637    }
638    if out.is_empty() {
639        let _ = std::fs::write(&mark, mine.len().to_string());
640        return 0;
641    }
642    use std::io::Write;
643    let open = text.as_bytes().last().is_some_and(|c| *c != b'\n');
644    let written = std::fs::OpenOptions::new()
645        .create(true)
646        .append(true)
647        .open(&team)
648        .and_then(|mut f| f.write_all(format!("{}{out}", if open { "\n" } else { "" }).as_bytes()));
649    if written.is_err() {
650        return 0;
651    }
652    let _ = std::fs::write(&mark, mine.len().to_string());
653    out.lines().count()
654}
655
656/// The belief view of the outcomes beside `lessons`: this machine's log plus, unless
657/// BORG_SHARE_OUTCOMES=off, the team's (`team_view`).
658pub fn read_team(lessons: &Path) -> Vec<Outcome> {
659    team_view(lessons, on())
660}
661
662/// Off: this machine's log as is. On: its records with each repo and cause as its `tag` (memory.rs only
663/// compares them for distinctness, so beliefs are the same and comparable with the team's), then the
664/// team's lines that count (`team_parts`). A remote session then counts once per lesson like a local
665/// one (memory.rs `derive`, `sessions`). learn.rs keys on raw causes: it reads this
666/// machine's log (`read`).
667pub fn team_view(lessons: &Path, on: bool) -> Vec<Outcome> {
668    let (mut out, team) = team_parts(lessons, on);
669    if on {
670        for o in &mut out {
671            o.repo = tag_repo(&o.repo);
672            o.cause = tag_repo(&o.cause);
673        }
674    }
675    out.extend(team);
676    out
677}
678
679/// This machine's records as written, and (on) every `TEAM_FILE` line of a shared kind whose session
680/// is not one of this machine's (that drops this node's own published copies, whatever its node id),
681/// once per (kind, session, lessons, ms, cause): one read of each file, so the two never disagree on
682/// where the local records end (ope.rs reads both). A kind this binary does not share (a newer node's,
683/// or an older one's hive verdict) is ignored, and so is a `tested` line naming a lesson the team's
684/// records do not show its session was given.
685pub fn team_parts(lessons: &Path, on: bool) -> (Vec<Outcome>, Vec<Outcome>) {
686    let local = read(&path_beside(lessons));
687    if !on {
688        return (local, Vec::new());
689    }
690    let mine: std::collections::HashSet<String> = local.iter().map(|o| tag(&o.session)).collect();
691    let text = std::fs::read_to_string(lessons.with_file_name(TEAM_FILE)).unwrap_or_default();
692    let lines: Vec<Shared> = text.lines().filter_map(|l| serde_json::from_str::<Shared>(l).ok()).collect();
693    // The lessons each team session's shared records name: a green run credits only lessons it was given.
694    let mut given: std::collections::HashMap<&str, std::collections::HashSet<&str>> = std::collections::HashMap::new();
695    for x in lines.iter().filter(|x| SHARED_KINDS.contains(&x.kind.as_str()) && x.kind != TESTED) {
696        given.entry(x.s.as_str()).or_default().extend(x.lessons.iter().map(String::as_str));
697    }
698    let mut seen: std::collections::HashSet<(String, String, Vec<String>, i64, String)> = std::collections::HashSet::new();
699    let mut team = Vec::new();
700    for x in lines.iter() {
701        if !SHARED_KINDS.contains(&x.kind.as_str()) || mine.contains(&x.s) || !seen.insert((x.kind.clone(), x.s.clone(), x.lessons.clone(), x.ms, x.c.clone()))
702        {
703            continue;
704        }
705        if x.kind == TESTED && !x.lessons.iter().all(|l| given.get(x.s.as_str()).is_some_and(|g| g.contains(l.as_str()))) {
706            continue;
707        }
708        team.push(Outcome {
709            kind: x.kind.clone(),
710            session: x.s.clone(),
711            lessons: x.lessons.clone(),
712            ms: x.ms,
713            repo: x.r.clone(),
714            p: x.p.clone(),
715            channel: x.channel.clone(),
716            cause: x.c.clone(),
717            credit: x.credit,
718            ..Default::default()
719        });
720    }
721    (local, team)
722}
723
724pub fn read(path: &Path) -> Vec<Outcome> {
725    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
726}
727
728/// Appends `o` in one write (learn.rs `append_line`: concurrent writers never tear a record), and
729/// counts it in the usage statistics (`count_result`).
730pub fn append(path: &Path, o: &Outcome) -> std::io::Result<()> {
731    crate::learn::append_jsonl(path, o)?;
732    if let Some(home) = path.parent() {
733        count_result(home, o);
734    }
735    Ok(())
736}
737
738/// `o` in the usage statistics of the store `home`: one event of its result kind with its lessons and
739/// channel. `asked` is already counted as the Stop's block, a `vetoed` result as the `veto` that
740/// credited it (veto.rs); a placebo's result is no result (`is_result`): its "changed" never joins the
741/// deliveries' counts. The judge (hook.rs) writes one record per routing check but counts the lessons
742/// of a call's channel as one event, as before checks had ids.
743pub fn count_result(home: &Path, o: &Outcome) {
744    if !is_result(o) {
745        return;
746    }
747    let kind = match o.kind.as_str() {
748        CHANGED => crate::stats::CHANGED,
749        IGNORED => crate::stats::IGNORED,
750        CORRECTED => crate::stats::CORRECTED,
751        NOT_CORRECTED => crate::stats::NOT_CORRECTED,
752        REGRETTED => crate::stats::REGRETTED,
753        TESTED => crate::stats::TESTED,
754        // Measurements, never results (`is_result` already turned them away).
755        UNCLEAR | REMAINS | NOT_ARISEN => return,
756        _ => return,
757    };
758    crate::stats::count(home, kind, &o.session, |e| {
759        e.repo = o.repo.clone();
760        e.lessons = o.lessons.clone();
761        e.channel = o.channel.clone();
762    });
763}
764
765/// Did the conscience ask `lesson` in `session` before `ms`?
766pub fn asked_before(outcomes: &[Outcome], session: &str, lesson: &str, ms: i64) -> bool {
767    outcomes.iter().any(|o| o.kind == ASKED && o.session == session && o.ms < ms && o.lessons.iter().any(|l| l == lesson))
768}
769
770/// Is a result (`kind`) already recorded for `lesson` in `session`? Keeps `borg learn` idempotent.
771pub fn has(outcomes: &[Outcome], kind: &str, session: &str, lesson: &str) -> bool {
772    outcomes.iter().any(|o| o.kind == kind && o.session == session && o.lessons.iter().any(|l| l == lesson))
773}
774
775/// The lessons a correction at `ms` in `session` shows were asked in vain, each asked in `session`
776/// before `ms` and not yet recorded as corrected or vetoed there (a veto is the stronger result): the lessons the correction `supersedes`
777/// (it contradicts them), and `target`, the lesson it was merged into (the same concern came up
778/// again). When `target` was asked as one side of a rendered disagreement, the engineer sided with
779/// it: the other sides of that disagreement were corrected, not `target`. Sorted, unique.
780pub fn contradicted(outcomes: &[Outcome], session: &str, ms: i64, target: Option<&str>, supersedes: &[String]) -> Vec<String> {
781    let mut out: Vec<String> = supersedes.to_vec();
782    if let Some(t) = target {
783        let sides: Vec<String> = outcomes
784            .iter()
785            .filter(|o| o.kind == ASKED && o.session == session && o.ms < ms)
786            .flat_map(|o| &o.groups)
787            .filter(|g| g.iter().any(|x| x == t))
788            .flatten()
789            .filter(|x| *x != t)
790            .cloned()
791            .collect();
792        match sides.is_empty() {
793            true => out.push(t.to_string()),
794            false => out.extend(sides),
795        }
796    }
797    out.retain(|l| asked_before(outcomes, session, l, ms) && !has(outcomes, CORRECTED, session, l) && !has(outcomes, VETOED, session, l));
798    out.sort();
799    out.dedup();
800    out
801}
802
803/// Was `o` a free delivery (channel `early` or `queued`): lessons handed over as context, no question?
804pub fn free(o: &Outcome) -> bool {
805    o.channel == "early" || o.channel == "queued"
806}
807
808/// The lessons a correction at `ms` in `session` shows were needed and not asked: of the lessons it
809/// `supersedes` and `target` (the lesson it was merged into), those a graded check of the session found
810/// applying before `ms` without choosing them, never asked as a question in the session before `ms` (a
811/// free delivery asks nothing: a lesson only handed over as context stays a miss for the cut,
812/// attention.rs), and not yet recorded as missed there. Sorted, unique.
813pub fn missed(outcomes: &[Outcome], session: &str, ms: i64, target: Option<&str>, supersedes: &[String]) -> Vec<String> {
814    let mut out: Vec<String> = supersedes.iter().cloned().chain(target.map(str::to_string)).collect();
815    let applied_unchosen = |l: &str| {
816        outcomes.iter().any(|o| o.kind == GRADED && o.session == session && o.ms < ms && o.lessons.iter().any(|x| x == l) && !o.chosen.iter().any(|x| x == l))
817    };
818    let questioned = |l: &str| outcomes.iter().any(|o| o.kind == ASKED && !free(o) && o.session == session && o.ms < ms && o.lessons.iter().any(|x| x == l));
819    out.retain(|l| applied_unchosen(l) && !questioned(l) && !has(outcomes, MISSED, session, l));
820    out.sort();
821    out.dedup();
822    out
823}
824
825/// Has `lesson` been credited for the regret `cause` already (in any session)?
826fn credited(outcomes: &[Outcome], cause: &str, lesson: &str) -> bool {
827    outcomes.iter().any(|o| o.kind == REGRETTED && o.cause == cause && o.lessons.iter().any(|l| l == lesson))
828}
829
830/// The lessons a regret blames: those asked in `session` (the deciding one) before `before_ms`, without
831/// a correction or a veto there already (both outrank regretted, as in memory.rs) and not yet credited
832/// for `cause`. When some of `target` (the lesson the regret was folded into, and the lessons that lesson
833/// supersedes) were asked, they share the blame alone: the same concern came up again, and when all of
834/// them already took a correction or a veto for it, nobody else is blamed. Otherwise every asked lesson
835/// shares it equally (the Shapley value of a symmetric game). A lesson a graded check found applying but
836/// did not ask was never shown, so it is not blamed. One record per lesson, sorted, at `ms`,
837/// with `credit = strength / n`; n counts the blamed lessons before the already-credited ones are left
838/// out, so a re-run writes nothing and the shares never change. Every record's session has an earlier
839/// `asked` record, so it never moves memory.rs's `Clock`.
840#[allow(clippy::too_many_arguments)]
841pub fn regretted(outcomes: &[Outcome], session: &str, before_ms: i64, cause: &str, target: &[String], strength: f64, ms: i64, repo: &str) -> Vec<Outcome> {
842    if session.is_empty() || before_ms <= 0 || strength <= 0.0 {
843        return Vec::new();
844    }
845    let mut asked: Vec<&str> =
846        outcomes.iter().filter(|o| o.kind == ASKED && o.session == session && o.ms < before_ms).flat_map(|o| o.lessons.iter().map(String::as_str)).collect();
847    asked.sort_unstable();
848    asked.dedup();
849    if asked.iter().any(|l| target.iter().any(|t| t == l)) {
850        asked.retain(|l| target.iter().any(|t| t == l));
851    }
852    asked.retain(|l| !has(outcomes, CORRECTED, session, l) && !has(outcomes, VETOED, session, l));
853    let credit = (strength / asked.len().max(1) as f64).min(1.0);
854    asked
855        .into_iter()
856        .filter(|l| !credited(outcomes, cause, l))
857        .map(|l| Outcome {
858            kind: REGRETTED.into(),
859            session: session.into(),
860            lessons: vec![l.into()],
861            ms,
862            repo: repo.into(),
863            cause: cause.into(),
864            credit,
865            ..Default::default()
866        })
867        .collect()
868}
869
870/// The `tested` records a session's own test runs earn (`tests`: its `<state>/<session>.tests`, `fail <ms>`
871/// and `pass <ms>` lines, hook.rs `capture_test`): each lesson asked as a question in `session` (a free
872/// delivery asks nothing, `free`: handed over at every prompt of its scope, it would gain from every
873/// test-first session) whose last test result after its first ask passed, after a failed run since that ask (red, then green) or a change the drone
874/// made on the lesson's point (`asked_then_changed`), and that has no `tested` record there yet, nor a
875/// correction or a veto (the engineer's word outranks a green run), nor `asked_then_ignored` (red, then
876/// green is also how a feature is built: it does not make an ignored question useful), nor
877/// `asked_not_arisen` on a question of it since its first ask without a change (the lesson's point never came up: a green run says nothing of
878/// it, and `tested` would move a belief `asked_not_arisen` is censored for). A pass before the ask says nothing
879/// of the lesson. One record per lesson, sorted, at that last pass. Every record's session has an
880/// earlier `asked` record, so it never moves memory.rs's `Clock`.
881pub fn tested(outcomes: &[Outcome], session: &str, tests: &str) -> Vec<Outcome> {
882    let mut runs: Vec<(i64, bool)> = tests
883        .lines()
884        .filter_map(|l| l.split_once(' '))
885        .filter_map(|(w, m)| Some((m.trim().parse::<i64>().ok()?, matches!(w, "pass" | "fail").then_some(w == "pass")?)))
886        .collect();
887    runs.sort_unstable();
888    // Each lesson's first ask in the session, and its repo.
889    let mut first: HashMap<&str, (i64, &str)> = HashMap::new();
890    for o in outcomes.iter().filter(|o| o.kind == ASKED && !free(o) && o.session == session) {
891        for l in &o.lessons {
892            let e = first.entry(l.as_str()).or_insert((o.ms, o.repo.as_str()));
893            if o.ms < e.0 {
894                *e = (o.ms, o.repo.as_str());
895            }
896        }
897    }
898    // A verdict that the question's point never came up, on a question of the lesson (not a placebo's
899    // or a free delivery's, none before its first ask): only that says the green run is not about it.
900    let not_arisen_on_ask = |l: &str, at: i64| {
901        outcomes
902            .iter()
903            .any(|o| o.kind == NOT_ARISEN && o.session == session && o.channel != PLACEBO && !free(o) && o.ms >= at && o.lessons.iter().any(|x| x == l))
904    };
905    let mut out: Vec<Outcome> = first
906        .into_iter()
907        .filter(|(l, _)| ![TESTED, CORRECTED, VETOED, IGNORED].iter().any(|k| has(outcomes, k, session, l)))
908        .filter(|&(l, (at, _))| !not_arisen_on_ask(l, at) || has(outcomes, CHANGED, session, l))
909        .filter_map(|(l, (at, repo))| {
910            let after = &runs[runs.partition_point(|r| r.0 <= at)..];
911            let &(ms, pass) = after.last()?;
912            (pass && (after.iter().any(|r| !r.1) || has(outcomes, CHANGED, session, l))).then(|| Outcome {
913                kind: TESTED.into(),
914                session: session.into(),
915                lessons: vec![l.into()],
916                ms,
917                repo: repo.into(),
918                ..Default::default()
919            })
920        })
921        .collect();
922    out.sort_by(|a, b| a.lessons.cmp(&b.lessons));
923    out
924}
925
926/// At session end: every lesson asked in `session` that has no result yet was not corrected, and every
927/// placebo of it without a verdict is closed. A vetoed lesson already has its result. `settle_with`,
928/// every unjudged lesson's reason `no_call`.
929pub fn settle(outcomes: &[Outcome], session: &str, ms: i64) -> Vec<Outcome> {
930    settle_with(outcomes, session, ms, &|_| UNJUDGED_NO_CALL)
931}
932
933/// `settle`, each record with its `reason`: `judged` when the judge gave the lesson's latest ask in the
934/// session a verdict that settles it (`settles`: `asked_then_changed`, `asked_ignored`,
935/// `asked_not_arisen`), else `unjudged(lesson)` (one of the
936/// `UNJUDGED_*`, from the session's state file, hook.rs `unjudged`); and its `check`, the check behind
937/// that latest `asked` record. A verdict on an earlier delivery says nothing of a later question: a
938/// stop that judges a lesson handed over free and then asks it writes the verdict first, and no judge
939/// call need follow the answer. Nor does a verdict on a free delivery (`free`) say anything of a
940/// question (or one on a question of a free delivery), whenever it came: an older binary's live check
941/// judged the earlier free delivery of a lesson asked since and not yet answered (hook.rs `pending`),
942/// and its question, never judged, then settles censored instead of staying open in the selection
943/// summary (select/summary.rs `settled`).
944/// Then every placebo of the session (a `delivery` routed `placebo`, which writes no `asked`) that no
945/// verdict and no earlier settle closed (`open_placebos`): `asked_not_corrected` on channel `placebo`
946/// (no result, `is_result`; it ends no ask's wait, `resolves`) with the placebo's check and repo, and
947/// the reason `unjudged(lesson)` when it is the lesson's latest delivery in the session, else
948/// `UNJUDGED_LATER`. So the selection summary censors an unjudged placebo as it does an unjudged ask
949/// (select/summary.rs `settled`), and the judge-missingness fit sees both.
950/// Not a result: memory.rs reads `asked_not_corrected` as censored, so a verdict recorded after it (a
951/// resumed session) counts once, alone.
952/// The asks first, sorted by lesson id, then the placebos by lesson id and check, so the appended lines
953/// are deterministic. A few passes over the session's records, whatever the number of asks.
954pub fn settle_with(outcomes: &[Outcome], session: &str, ms: i64, unjudged: &dyn Fn(&str) -> &'static str) -> Vec<Outcome> {
955    let mine: Vec<&Outcome> = outcomes.iter().filter(|o| o.session == session).collect();
956    let resolved: std::collections::HashSet<&str> = mine.iter().filter(|o| resolves(o)).flat_map(|o| o.lessons.iter().map(String::as_str)).collect();
957    // Per lesson: its verdicts' (ms, check, on a free delivery).
958    let mut verdicts: HashMap<&str, Vec<(i64, &str, bool)>> = HashMap::new();
959    for o in mine.iter().filter(|o| settles(&o.kind) && o.channel != PLACEBO) {
960        for l in &o.lessons {
961            verdicts.entry(l.as_str()).or_default().push((o.ms, o.check.as_str(), free(o)));
962        }
963    }
964    // Per lesson: the first of its asks' repos in sort order (as before checks were kept), and the
965    // ms, check and whether free of its latest ask.
966    let mut asked: std::collections::BTreeMap<&str, (&str, i64, &str, bool)> = std::collections::BTreeMap::new();
967    // Per lesson: the ms of its latest delivery, an `asked` for every one the drone sees and a
968    // placebo's `delivery`.
969    let mut latest: HashMap<&str, i64> = HashMap::new();
970    for o in mine.iter().filter(|o| o.kind == ASKED || is_placebo(o)) {
971        for l in o.lessons.iter().map(String::as_str) {
972            let t = latest.entry(l).or_insert(o.ms);
973            *t = (*t).max(o.ms);
974            if o.kind != ASKED || resolved.contains(l) {
975                continue;
976            }
977            let e = asked.entry(l).or_insert((o.repo.as_str(), o.ms, o.check.as_str(), free(o)));
978            e.0 = e.0.min(o.repo.as_str());
979            if o.ms >= e.1 {
980                (e.1, e.2, e.3) = (o.ms, o.check.as_str(), free(o));
981            }
982        }
983    }
984    // A verdict on the latest ask is on the same kind of delivery (a question, or free) and came after it
985    // (the judge sees a delivery in a later call), or names its check. Time first: an older binary's stop
986    // wrote one `asked` naming the stop's check also for a lesson another check routed (hook.rs
987    // `#borg-routed`), whose verdict names that check; a stop now writes one `asked` per routing check
988    // (hook.rs `stop_ask`).
989    let judged = |l: &str, at: i64, check: &str, as_free: bool| {
990        verdicts.get(l).is_some_and(|v| v.iter().any(|&(t, c, f)| f == as_free && (t > at || (!check.is_empty() && c == check))))
991    };
992    let mut out: Vec<Outcome> = asked
993        .into_iter()
994        .map(|(l, (repo, at, check, as_free))| Outcome {
995            kind: NOT_CORRECTED.into(),
996            session: session.into(),
997            lessons: vec![l.into()],
998            ms,
999            repo: repo.into(),
1000            check: check.into(),
1001            reason: if judged(l, at, check, as_free) { UNJUDGED_NONE } else { unjudged(l) }.into(),
1002            // The binary that settled: from 0.7.3 on, the selection summary closes every delivery of the
1003            // lesson the session left without a verdict (select/summary.rs `settled`).
1004            version: env!("CARGO_PKG_VERSION").into(),
1005            ..Default::default()
1006        })
1007        .collect();
1008    let mut placebos: Vec<Outcome> = open_placebos(&mine)
1009        .into_iter()
1010        .map(|(l, d)| Outcome {
1011            kind: NOT_CORRECTED.into(),
1012            session: session.into(),
1013            lessons: vec![l.into()],
1014            ms,
1015            repo: d.repo.clone(),
1016            channel: PLACEBO.into(),
1017            check: d.check.clone(),
1018            // Delivered again later, the lesson's later delivery is the one the judge saw.
1019            reason: if latest.get(l).is_some_and(|&t| t > d.ms) { UNJUDGED_LATER } else { unjudged(l) }.into(),
1020            ..Default::default()
1021        })
1022        .collect();
1023    placebos.sort_by(|a, b| (&a.lessons, &a.check).cmp(&(&b.lessons, &b.check)));
1024    out.extend(placebos);
1025    out
1026}
1027
1028/// Does `o` end an ask's wait for its result (`settle`)? A placebo's settle record ends none: a
1029/// placebo is never asked, and the lesson may be asked later in the session.
1030fn resolves(o: &Outcome) -> bool {
1031    matches!(o.kind.as_str(), CORRECTED | NOT_CORRECTED | VETOED) && o.channel != PLACEBO
1032}
1033
1034/// A placebo's delivery (`delivery` routed `placebo`, hook.rs `queued`): judged blindly, never shown,
1035/// so it writes no `asked`.
1036fn is_placebo(o: &Outcome) -> bool {
1037    o.kind == DELIVERY && o.route == ROUTE_PLACEBO
1038}
1039
1040/// Does `r`, a placebo channel's verdict that settles (`settles`: `asked_then_changed`, `asked_ignored`,
1041/// `asked_not_arisen`) or settle record (`asked_not_corrected`), close placebo delivery `d` of its
1042/// lesson? A settle record names the placebo's own check, so it closes exactly that one, and settling
1043/// again writes nothing. A verdict names the check it judged (a check routes a lesson once), or, a
1044/// record from before check ids, came after the placebo. An unclear verdict or a remaining consequence
1045/// closes nothing, as it leaves an ask pending.
1046fn closes(r: &Outcome, d: &Outcome) -> bool {
1047    match r.kind.as_str() {
1048        NOT_CORRECTED => r.check == d.check,
1049        k if settles(k) && (r.check.is_empty() || d.check.is_empty()) => r.ms > d.ms,
1050        k if settles(k) => r.check == d.check,
1051        _ => false,
1052    }
1053}
1054
1055/// The open placebos among `mine` (one session's records), in log order: per placebo delivery
1056/// (`is_placebo`) and lesson, none of the session's placebo records `closes` it.
1057fn open_placebos<'a>(mine: &[&'a Outcome]) -> Vec<(&'a str, &'a Outcome)> {
1058    let mut closers: HashMap<&str, Vec<&Outcome>> = HashMap::new();
1059    for &o in mine.iter().filter(|o| o.channel == PLACEBO && !is_placebo(o)) {
1060        for l in &o.lessons {
1061            closers.entry(l.as_str()).or_default().push(o);
1062        }
1063    }
1064    mine.iter()
1065        .copied()
1066        .filter(|o| is_placebo(o))
1067        .flat_map(|d| d.lessons.iter().map(move |l| (l.as_str(), d)))
1068        .filter(|&(l, d)| !closers.get(l).is_some_and(|rs| rs.iter().any(|r| closes(r, d))))
1069        .collect()
1070}
1071
1072/// Sessions with asks or placebos that are idle at `now`: their newest ask or placebo and newest
1073/// episode (`last_episode`, newest episode ms per session) are at least `IDLE_MS` old. Sorted. Only
1074/// sessions with an ask still without its result or an open placebo (`open_placebos`), so `borg learn`
1075/// (under the store lock) settles what is new, not every session it ever settled: one pass over the
1076/// log.
1077pub fn idle_sessions<'a>(outcomes: &'a [Outcome], last_episode: &HashMap<String, i64>, now: i64) -> Vec<&'a str> {
1078    let mut newest: HashMap<&str, i64> = HashMap::new();
1079    let (mut asked, mut resolved) = (std::collections::HashSet::new(), std::collections::HashSet::new());
1080    // Per session, its placebo channel's records: deliveries, verdicts and settles.
1081    let mut placebos: HashMap<&str, Vec<&Outcome>> = HashMap::new();
1082    for o in outcomes {
1083        if o.channel == PLACEBO || is_placebo(o) {
1084            placebos.entry(o.session.as_str()).or_default().push(o);
1085        }
1086        if o.kind == ASKED || is_placebo(o) {
1087            let m = newest.entry(o.session.as_str()).or_insert(o.ms);
1088            *m = (*m).max(o.ms);
1089        }
1090        let into = match o.kind.as_str() {
1091            ASKED => &mut asked,
1092            _ if resolves(o) => &mut resolved,
1093            _ => continue,
1094        };
1095        into.extend(o.lessons.iter().map(|l| (o.session.as_str(), l.as_str())));
1096    }
1097    let mut open: std::collections::HashSet<&str> = asked.difference(&resolved).map(|(s, _)| *s).collect();
1098    open.extend(placebos.iter().filter(|(_, rs)| !open_placebos(rs).is_empty()).map(|(s, _)| *s));
1099    let mut idle: Vec<&str> = newest
1100        .into_iter()
1101        .filter(|&(s, ms)| open.contains(s) && ms.max(last_episode.get(s).copied().unwrap_or(i64::MIN)) <= now - IDLE_MS)
1102        .map(|(s, _)| s)
1103        .collect();
1104    idle.sort();
1105    idle
1106}
1107
1108/// Usefulness of a lesson from its asked outcomes:
1109///
1110/// w = (1 + not_corrected) / (1 + not_corrected + 2 * corrected)
1111///
1112/// The +1 is a neutral prior, so a lesson without outcomes keeps w = 1 and a single bad session
1113/// cannot zero it. A correction of the same concern right after asking is strong evidence the
1114/// question did not help (too generic, or it does not change what the drone does), while a
1115/// session without correction is weak evidence (the engineer may simply not have looked), so a
1116/// correction counts twice. w stays in (0, 1]: outcomes only demote, BM25 relevance still decides
1117/// what is on topic. One corrected session gives 1/3; one corrected and one not gives 1/2.
1118pub fn weight(not_corrected: u32, corrected: u32) -> f64 {
1119    let (n, c) = (not_corrected as f64, corrected as f64);
1120    (1.0 + n) / (1.0 + n + f64::from(CORRECTED_W) * c)
1121}
1122
1123/// The weight of every lesson that has outcomes. Each (session, lesson) counts once; corrected wins.
1124pub fn weights(outcomes: &[Outcome]) -> HashMap<String, f64> {
1125    counts(outcomes).into_iter().map(|(l, (n, c))| (l, weight(n, c))).collect()
1126}
1127
1128/// Per lesson with results: (sessions not corrected, sessions corrected). Each (session, lesson)
1129/// counts once; corrected wins. Used by `weights`.
1130pub fn counts(outcomes: &[Outcome]) -> HashMap<String, (u32, u32)> {
1131    sessions(outcomes).into_iter().map(|(l, ss)| (l.to_string(), tally(ss.values(), i64::MIN))).collect()
1132}
1133
1134/// Per lesson, per session with a result: (corrected, ms of the session's earliest result for the
1135/// lesson). Corrected wins over a later (resumed) settle. trust.rs filters these by when an engineer
1136/// joined the lesson. A veto (`vetoed`) counts as a correction here, so trust and `weight` see it too
1137/// (as heavy as a correction; only memory.rs weighs it above one).
1138pub fn sessions(outcomes: &[Outcome]) -> HashMap<&str, HashMap<&str, (bool, i64)>> {
1139    let mut out: HashMap<&str, HashMap<&str, (bool, i64)>> = HashMap::new();
1140    // A placebo's record is no result (`is_result`).
1141    for o in outcomes.iter().filter(|o| is_result(o)) {
1142        let corrected = match o.kind.as_str() {
1143            CORRECTED | VETOED => true,
1144            NOT_CORRECTED => false,
1145            _ => continue,
1146        };
1147        for l in &o.lessons {
1148            let r = out.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert((corrected, o.ms));
1149            r.0 |= corrected;
1150            r.1 = r.1.min(o.ms);
1151        }
1152    }
1153    out
1154}
1155
1156/// (sessions not corrected, sessions corrected) among `sessions` results whose earliest result is
1157/// after `since`.
1158pub fn tally<'a>(sessions: impl IntoIterator<Item = &'a (bool, i64)>, since: i64) -> (u32, u32) {
1159    sessions.into_iter().filter(|(_, ms)| *ms > since).fold((0, 0), |(n, c), (corrected, _)| match corrected {
1160        true => (n, c + 1),
1161        false => (n + 1, c),
1162    })
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168
1169    fn o(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
1170        Outcome {
1171            kind: kind.into(),
1172            session: session.into(),
1173            lessons: lessons.iter().map(|l| l.to_string()).collect(),
1174            ms,
1175            repo: "r".into(),
1176            ..Default::default()
1177        }
1178    }
1179
1180    #[test]
1181    fn missed_is_an_applied_unasked_lesson_a_later_correction_lands_on() {
1182        let graded = |ms: i64, chosen: &[&str]| Outcome {
1183            kind: GRADED.into(),
1184            session: "s".into(),
1185            lessons: vec!["A".into(), "B".into()],
1186            ms,
1187            chosen: chosen.iter().map(|x| x.to_string()).collect(),
1188            ..Default::default()
1189        };
1190        let none: Vec<String> = Vec::new();
1191        let log = vec![graded(10, &["B"])];
1192        assert_eq!(missed(&log, "s", 20, Some("A"), &none), ["A"]);
1193        assert_eq!(missed(&log, "s", 20, None, &["A".into(), "B".into()]), ["A"], "B was chosen");
1194        assert!(missed(&log, "s", 5, Some("A"), &none).is_empty(), "a correction before the check");
1195        assert!(missed(&log, "t", 20, Some("A"), &none).is_empty(), "another session");
1196        assert!(missed(&log, "s", 20, Some("C"), &none).is_empty(), "never applied");
1197        let mut asked = log.clone();
1198        asked.push(o(ASKED, "s", &["A"], 15));
1199        assert!(missed(&asked, "s", 20, Some("A"), &none).is_empty(), "asked later in the session: not a miss");
1200        let mut free = log.clone();
1201        free.push(Outcome { channel: "queued".into(), ..o(ASKED, "s", &["A"], 15) });
1202        free.push(Outcome { channel: "early".into(), ..o(ASKED, "s", &["A"], 16) });
1203        assert_eq!(missed(&free, "s", 20, Some("A"), &none), ["A"], "handed over free, never asked: still a miss");
1204        let mut done = log.clone();
1205        done.push(o(MISSED, "s", &["A"], 18));
1206        assert!(missed(&done, "s", 20, Some("A"), &none).is_empty(), "recorded once");
1207    }
1208
1209    #[test]
1210    fn weight_formula() {
1211        assert_eq!(weight(0, 0), 1.0);
1212        assert_eq!(weight(5, 0), 1.0);
1213        assert!((weight(0, 1) - 1.0 / 3.0).abs() < 1e-12);
1214        assert_eq!(weight(1, 1), 0.5);
1215        assert!(weight(0, 3) < weight(0, 1));
1216    }
1217
1218    #[test]
1219    fn weights_count_each_session_once_and_corrected_wins() {
1220        let all = vec![
1221            o(ASKED, "s1", &["A", "B"], 10),
1222            o(CORRECTED, "s1", &["A"], 20),
1223            o(CORRECTED, "s1", &["A"], 30),     // second correction in the same session: still one
1224            o(NOT_CORRECTED, "s1", &["A"], 40), // resumed session settled later: corrected wins
1225            o(NOT_CORRECTED, "s1", &["B"], 40),
1226            o(NOT_CORRECTED, "s2", &["A"], 50),
1227        ];
1228        let w = weights(&all);
1229        assert_eq!(w["A"], weight(1, 1));
1230        assert_eq!(w["B"], 1.0);
1231        assert!(!w.contains_key("C"));
1232    }
1233
1234    #[test]
1235    fn a_session_whose_tests_go_green_after_an_ask_credits_it_once() {
1236        let mut log = vec![o(ASKED, "s", &["A", "B"], 10), o(ASKED, "t", &["A"], 10)];
1237        // Red after the ask, then green: one credit per lesson asked, dated at the pass.
1238        let got = tested(&log, "s", "fail 20\npass 30\n");
1239        assert_eq!(got.iter().map(|x| (x.kind.as_str(), x.lessons[0].as_str(), x.ms)).collect::<Vec<_>>(), [(TESTED, "A", 30), (TESTED, "B", 30)]);
1240        // Idempotent: a re-run with the records written writes nothing.
1241        log.extend(got);
1242        assert!(tested(&log, "s", "fail 20\npass 30\n").is_empty());
1243        // Only the session's own asks: t ran no tests.
1244        assert!(tested(&log, "t", "").is_empty(), "no tests file: nothing");
1245    }
1246
1247    #[test]
1248    fn a_green_run_credits_only_after_the_ask_and_with_a_reason() {
1249        let log = vec![o(ASKED, "s", &["A"], 50)];
1250        assert!(tested(&log, "s", "fail 20\npass 30\n").is_empty(), "red and green before the ask");
1251        assert!(tested(&log, "s", "fail 20\npass 60\n").is_empty(), "the failure before the ask: green proves nothing new");
1252        assert!(tested(&log, "s", "fail 60\npass 70\nfail 80\n").is_empty(), "last run red");
1253        assert!(tested(&log, "s", "pass 60\n").is_empty(), "green alone, nothing changed");
1254        assert!(tested(&log, "s", "pass 60\npass 70 \ngarbage\n").is_empty());
1255        let early = [Outcome { channel: "early".into(), ..o(ASKED, "e", &["A"], 10) }];
1256        assert!(tested(&early, "e", "fail 20\npass 30\n").is_empty(), "a free delivery asked nothing");
1257        // Green after the drone changed its work on the lesson: a credit.
1258        let changed = [log.clone(), vec![o(CHANGED, "s", &["A"], 55)]].concat();
1259        assert_eq!(tested(&changed, "s", "pass 60\n").len(), 1);
1260        // A correction or a veto of the lesson in the session outranks a green run; an ignored question
1261        // gains nothing from the drone fixing its own failing test.
1262        for k in [CORRECTED, VETOED, IGNORED] {
1263            let worse = [log.clone(), vec![o(k, "s", &["A"], 55)]].concat();
1264            assert!(tested(&worse, "s", "fail 60\npass 70\n").is_empty(), "{k}");
1265        }
1266        // Lines out of order still find the last run; the first ask decides what is after it.
1267        let twice = [log.clone(), vec![o(ASKED, "s", &["A"], 65)]].concat();
1268        assert_eq!(tested(&twice, "s", "pass 70\nfail 60\n")[0].ms, 70);
1269    }
1270
1271    #[test]
1272    fn settle_marks_only_unresolved_asks_of_this_session() {
1273        let all = vec![o(ASKED, "s1", &["B", "A"], 10), o(ASKED, "s1", &["A", "C"], 15), o(CORRECTED, "s1", &["A"], 20), o(ASKED, "s2", &["D"], 10)];
1274        let settled = settle(&all, "s1", 99);
1275        assert_eq!(settled, vec![unjudged("s1", "B", UNJUDGED_NO_CALL), unjudged("s1", "C", UNJUDGED_NO_CALL)]);
1276        assert!(asked_before(&all, "s1", "A", 11) && !asked_before(&all, "s1", "C", 15) && !asked_before(&all, "s2", "A", 99));
1277    }
1278
1279    /// An ask's settle record, as this binary writes it (`settle_with`: with its version).
1280    fn unjudged(session: &str, lesson: &str, reason: &str) -> Outcome {
1281        Outcome { reason: reason.into(), version: env!("CARGO_PKG_VERSION").into(), ..o(NOT_CORRECTED, session, &[lesson], 99) }
1282    }
1283
1284    #[test]
1285    fn a_settled_lesson_says_why_it_had_no_verdict_and_names_its_latest_check() {
1286        let ask = |lessons: &[&str], ms: i64, check: &str| Outcome { check: check.into(), ..o(ASKED, "s", lessons, ms) };
1287        let placebo = Outcome { channel: PLACEBO.into(), ..o(CHANGED, "s", &["C"], 30) };
1288        let all = vec![ask(&["A", "B", "C"], 10, "c1"), ask(&["B"], 20, "c2"), o(IGNORED, "s", &["A"], 25), placebo];
1289        let why = |l: &str| if l == "B" { UNJUDGED_TRIES } else { UNJUDGED_SLOTS };
1290        let got = settle_with(&all, "s", 99, &why);
1291        let want = |l: &str, reason: &str, check: &str| Outcome { check: check.into(), ..unjudged("s", l, reason) };
1292        assert_eq!(got, vec![want("A", UNJUDGED_NONE, "c1"), want("B", UNJUDGED_TRIES, "c2"), want("C", UNJUDGED_SLOTS, "c1")], "a placebo's verdict is none");
1293    }
1294
1295    /// A verdict on an earlier delivery of the lesson is none for its later question: handed over free,
1296    /// judged ignored by a stop that then asks it, and the session ends after the answer, so no judge
1297    /// saw the question. A verdict after the question, or on its check, is its own.
1298    #[test]
1299    fn a_verdict_on_an_earlier_delivery_leaves_the_later_question_unjudged() {
1300        let ask = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(ASKED, "s", &["L"], ms) };
1301        let verdict = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(IGNORED, "s", &["L"], ms) };
1302        let why = |log: &[Outcome]| settle_with(log, "s", 99, &|_| UNJUDGED_NO_CALL).into_iter().map(|x| (x.reason, x.check)).collect::<Vec<_>>();
1303        let said = |reason: &str, check: &str| vec![(reason.to_string(), check.to_string())];
1304        // The stop writes its verdict on the free delivery, then the question, in the same millisecond.
1305        let free_then_asked = vec![ask("queued", 10, "cF"), verdict("queued", 20, "cF"), ask("stop", 20, "cS")];
1306        assert_eq!(why(&free_then_asked), said(UNJUDGED_NO_CALL, "cS"));
1307        let answered = [free_then_asked, vec![verdict("stop", 30, "cS")]].concat();
1308        assert_eq!(why(&answered), said(UNJUDGED_NONE, "cS"));
1309        // A lesson another check routed, asked at the stop: its verdict names the routing check.
1310        assert_eq!(why(&[ask("stop", 20, "cS"), verdict("stop", 30, "cL")]), said(UNJUDGED_NONE, "cS"));
1311        // On the question's check, though its clock stepped back.
1312        assert_eq!(why(&[ask("stop", 20, "cS"), verdict("stop", 15, "cS")]), said(UNJUDGED_NONE, "cS"));
1313    }
1314
1315    /// An older binary's live check judged a lesson's earlier free delivery after the lesson was asked
1316    /// live and before the answer (hook.rs `pending`); no judge saw the question. The session ends: the
1317    /// free verdict is not the question's, so the question settles censored (an unjudged reason), which
1318    /// the selection summary reads as its label, instead of staying open for good. A free delivery's own
1319    /// verdict still settles it as judged.
1320    #[test]
1321    fn a_free_deliverys_verdict_after_a_later_question_leaves_the_question_unjudged() {
1322        let ask = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(ASKED, "s", &["L"], ms) };
1323        let verdict = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(CHANGED, "s", &["L"], ms) };
1324        let why = |log: &[Outcome]| settle_with(log, "s", 99, &|_| UNJUDGED_TRIES).into_iter().map(|x| (x.reason, x.check)).collect::<Vec<_>>();
1325        let said = |reason: &str, check: &str| vec![(reason.to_string(), check.to_string())];
1326        let stuck = vec![ask("queued", 10, "cA"), ask("live", 20, "cB"), verdict("queued", 30, "cA")];
1327        assert_eq!(why(&stuck), said(UNJUDGED_TRIES, "cB"));
1328        let early = vec![ask("early", 10, "cA"), ask("stop", 20, "cB"), verdict("early", 30, "cA")];
1329        assert_eq!(why(&early), said(UNJUDGED_TRIES, "cB"));
1330        assert_eq!(why(&[stuck.clone(), vec![verdict("live", 40, "cB")]].concat()), said(UNJUDGED_NONE, "cB"), "the question's own verdict");
1331        assert_eq!(why(&[ask("queued", 10, "cA"), verdict("queued", 30, "cA")]), said(UNJUDGED_NONE, "cA"), "a free delivery's own");
1332
1333        // The selection summary labels the stuck question censored once the session settles.
1334        use crate::select::{
1335            Route,
1336            summary::{Label, Summary, fold},
1337        };
1338        let graded = |check: &str, ms: i64, channel: &str| Outcome {
1339            grades: vec![3],
1340            ranks: vec![0],
1341            applied: 1,
1342            channel: channel.into(),
1343            check: check.into(),
1344            ..o(GRADED, "s", &["L"], ms)
1345        };
1346        let mut log = vec![Outcome { free: vec!["L".into()], ..graded("cA", 5, "live") }, Outcome { chosen: vec!["L".into()], ..graded("cB", 15, "live") }];
1347        log.extend(stuck);
1348        log.extend(settle_with(&log, "s", 99, &|_| UNJUDGED_TRIES));
1349        let text: String = log.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1350        let s = fold(&text, Summary::default());
1351        let label = |route: Route| s.applies.iter().find(|a| a.route == route).map(|a| a.label);
1352        assert_eq!(label(Route::Ask), Some(Label::Censored), "{:?}", s.applies);
1353    }
1354
1355    /// The later delivery wins the judge (hook.rs `pending`): handed over free at cA then asked at cB, or
1356    /// asked at cA then handed over free at cB, the lesson is judged on cB alone. Once the session settles
1357    /// (a settle naming its binary), the delivery the judge never got to is censored, never left open,
1358    /// whose reason says "judged".
1359    #[test]
1360    fn a_settle_leaves_no_delivery_of_its_lesson_open() {
1361        use crate::select::{
1362            Route,
1363            summary::{Label, Summary, fold},
1364        };
1365        let graded = |check: &str, ms: i64, ask: bool| Outcome {
1366            grades: vec![2],
1367            ranks: vec![0],
1368            applied: 1,
1369            channel: "live".into(),
1370            check: check.into(),
1371            chosen: if ask { vec!["L".into()] } else { Vec::new() },
1372            free: if ask { Vec::new() } else { vec!["L".into()] },
1373            ..o(GRADED, "s", &["L"], ms)
1374        };
1375        let asked = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(ASKED, "s", &["L"], ms) };
1376        let verdict = |channel: &str, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(CHANGED, "s", &["L"], 40) };
1377        for (first_ask, channels) in [(false, ["queued", "live"]), (true, ["live", "queued"])] {
1378            let mut log = vec![graded("cA", 5, first_ask), asked(channels[0], 10, "cA"), graded("cB", 15, !first_ask), asked(channels[1], 20, "cB")];
1379            log.push(verdict(channels[1], "cB"));
1380            let settled = settle_with(&log, "s", 99, &|_| UNJUDGED_TRIES);
1381            assert_eq!(settled.iter().map(|x| (x.reason.as_str(), x.check.as_str())).collect::<Vec<_>>(), [(UNJUDGED_NONE, "cB")]);
1382            log.extend(settled);
1383            let text: String = log.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1384            let s = fold(&text, Summary::default());
1385            let label = |route: Route| s.applies.iter().find(|a| a.route == route).map(|a| a.label);
1386            let (a, b) = if first_ask { (Route::Ask, Route::Free) } else { (Route::Free, Route::Ask) };
1387            assert_eq!((label(a), label(b)), (Some(Label::Censored), Some(Label::Changed)), "first asked {first_ask}: {:?}", s.applies);
1388        }
1389        // An older binary judged the free delivery while the question waited, and its settle (no
1390        // version) said "judged" for the question: it folds as when its hooks decided on it, the
1391        // question open, so the replay still reproduces those decisions.
1392        let mut log = vec![graded("cA", 5, false), asked("queued", 10, "cA"), graded("cB", 15, true), asked("live", 20, "cB"), verdict("queued", "cA")];
1393        log.push(Outcome { reason: UNJUDGED_NONE.into(), check: "cB".into(), ..o(NOT_CORRECTED, "s", &["L"], 99) });
1394        let text: String = log.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1395        let s = fold(&text, Summary::default());
1396        let labels: Vec<(Route, Label)> = s.applies.iter().map(|a| (a.route, a.label)).collect();
1397        assert_eq!(labels, [(Route::Free, Label::Changed), (Route::Ask, Label::Open)]);
1398    }
1399
1400    fn placebo(lessons: &[&str], check: &str, ms: i64) -> Outcome {
1401        Outcome { channel: PLACEBO.into(), check: check.into(), route: ROUTE_PLACEBO.into(), ..o(DELIVERY, "s", lessons, ms) }
1402    }
1403
1404    /// A placebo writes no `asked`, only its `delivery`: one the judge gave no verdict settles too, on the
1405    /// placebo channel (no result, and no ask's end), with its own check and reason. Delivered again
1406    /// later, the later delivery took its place in the judge's queue.
1407    #[test]
1408    fn an_unjudged_placebo_settles_on_its_own_channel() {
1409        let on = |kind: &str, lesson: &str, check: &str, ms: i64| Outcome { channel: PLACEBO.into(), check: check.into(), ..o(kind, "s", &[lesson], ms) };
1410        let log = vec![
1411            placebo(&["A", "B"], "p1", 10),
1412            on(IGNORED, "A", "p1", 15),
1413            on(UNCLEAR, "B", "p1", 15),
1414            placebo(&["C"], "p2", 20),
1415            Outcome { channel: "queued".into(), check: "c3".into(), ..o(ASKED, "s", &["C"], 30) },
1416            Outcome { route: ROUTE_DROPPED_DONE.into(), ..placebo(&["D"], "p4", 40) },
1417        ];
1418        let why = |l: &str| if l == "B" { UNJUDGED_TRIES } else { UNJUDGED_NO_CALL };
1419        let got = settle_with(&log, "s", 99, &why);
1420        let closed =
1421            |l: &str, check: &str, reason: &str| Outcome { channel: PLACEBO.into(), check: check.into(), version: String::new(), ..unjudged("s", l, reason) };
1422        let asked = Outcome { check: "c3".into(), ..unjudged("s", "C", UNJUDGED_NO_CALL) };
1423        let want = vec![asked, closed("B", "p1", UNJUDGED_TRIES), closed("C", "p2", UNJUDGED_LATER)];
1424        assert_eq!(got, want, "A has its verdict; D never reached the judge's queue");
1425        assert!(got[1..].iter().all(|x| !is_result(x) && share(x, "n1").is_none()), "no result, never shared");
1426        // Settled once: the same records close their placebos, so settling again writes nothing.
1427        let mut resumed = [log, got].concat();
1428        assert!(settle_with(&resumed, "s", 120, &why).is_empty());
1429        // Resumed: B asked, E a new placebo. B's placebo settle ended no ask's wait.
1430        resumed.extend([Outcome { channel: "stop".into(), check: "c5".into(), ..o(ASKED, "s", &["B"], 110) }, placebo(&["E"], "p6", 111)]);
1431        let again: Vec<(String, String, String)> =
1432            settle_with(&resumed, "s", 120, &why).into_iter().map(|x| (x.lessons.join(","), x.channel, x.check)).collect();
1433        assert_eq!(again, [("B".to_string(), String::new(), "c5".to_string()), ("E".to_string(), PLACEBO.to_string(), "p6".to_string())]);
1434    }
1435
1436    /// Judge v3 (0.7.5): `asked_not_arisen` settles its delivery (a question's settle says "judged", a
1437    /// placebo is closed) while staying no result and never shared; `asked_consequence_remains` leaves
1438    /// it pending as `asked_unclear` does. The selection summary labels each apart, an older
1439    /// `asked_unclear` still `Unclear`, and a pending verdict never overwrites a settling one.
1440    #[test]
1441    fn not_arisen_settles_its_delivery_and_a_remaining_consequence_leaves_it_pending() {
1442        use crate::select::{
1443            Route,
1444            summary::{Label, Summary, fold},
1445        };
1446        let ask = |l: &str, check: &str| Outcome { channel: "stop".into(), check: check.into(), ..o(ASKED, "s", &[l], 10) };
1447        let on = |kind: &str, l: &str, check: &str, channel: &str| Outcome {
1448            channel: channel.into(),
1449            check: check.into(),
1450            judge: 3,
1451            reasons: vec!["only the docs changed".into()],
1452            ..o(kind, "s", &[l], 20)
1453        };
1454        let log = vec![
1455            ask("A", "cA"),
1456            on(NOT_ARISEN, "A", "cA", "stop"),
1457            ask("B", "cB"),
1458            on(REMAINS, "B", "cB", "stop"),
1459            placebo(&["P"], "pP", 10),
1460            on(NOT_ARISEN, "P", "pP", PLACEBO),
1461            placebo(&["Q"], "pQ", 10),
1462            on(REMAINS, "Q", "pQ", PLACEBO),
1463        ];
1464        assert!(log.iter().filter(|x| x.kind == NOT_ARISEN || x.kind == REMAINS).all(|x| !is_result(x) && share(x, "n1").is_none()));
1465        assert!(settles(NOT_ARISEN) && settles(CHANGED) && settles(IGNORED) && !settles(REMAINS) && !settles(UNCLEAR));
1466        let got: Vec<(String, String, String)> =
1467            settle_with(&log, "s", 99, &|_| UNJUDGED_TRIES).into_iter().map(|x| (x.lessons.join(","), x.channel, x.reason)).collect();
1468        let row = |l: &str, channel: &str, reason: &str| (l.to_string(), channel.to_string(), reason.to_string());
1469        assert_eq!(got, [row("A", "", UNJUDGED_NONE), row("B", "", UNJUDGED_TRIES), row("Q", PLACEBO, UNJUDGED_TRIES)], "P closed by its verdict");
1470        // The reason travels on the record and reads back; an older line without it reads empty.
1471        let line = serde_json::to_string(&log[1]).unwrap();
1472        assert!(line.contains(r#""reasons":["only the docs changed"]"#), "{line}");
1473        assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), log[1]);
1474        let old: Outcome = serde_json::from_str(r#"{"kind":"asked_unclear","session":"s","lessons":["A"],"ms":1,"repo":"r","channel":"stop"}"#).unwrap();
1475        assert!(old.reasons.is_empty() && !is_result(&old) && !settles(&old.kind));
1476
1477        // The summary: each graded apply its own label.
1478        let graded = |l: &str, check: &str, ask: bool, placebo: bool| Outcome {
1479            grades: vec![3],
1480            ranks: vec![0],
1481            applied: 1,
1482            channel: "stop".into(),
1483            check: check.into(),
1484            chosen: if ask { vec![l.into()] } else { Vec::new() },
1485            placebo: if placebo { vec![l.into()] } else { Vec::new() },
1486            psi: if placebo { 0.5 } else { 0.0 },
1487            ..o(GRADED, "s", &[l], 5)
1488        };
1489        let mut all = vec![graded("A", "cA", true, false), graded("B", "cB", true, false), graded("C", "cC", true, false)];
1490        all.push(graded("P", "pP", false, true));
1491        all.extend(log);
1492        all.extend([ask("C", "cC"), on(UNCLEAR, "C", "cC", "stop")]);
1493        // A later pending verdict does not undo a settling one; a later settling one replaces a pending one.
1494        all.extend([on(UNCLEAR, "A", "cA", "stop"), on(CHANGED, "B", "cB", "stop")]);
1495        let text: String = all.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1496        let s = fold(&text, Summary::default());
1497        let label = |l: &str| {
1498            let i = s.lessons.iter().position(|x| x == l).unwrap() as u32;
1499            s.applies.iter().find(|a| a.lesson == i).map(|a| (a.route, a.label, a.judge.is_some()))
1500        };
1501        assert_eq!(label("A"), Some((Route::Ask, Label::NotArisen, true)), "{:?}", s.applies);
1502        assert_eq!(label("B"), Some((Route::Ask, Label::Changed, true)));
1503        assert_eq!(label("C"), Some((Route::Ask, Label::Unclear, false)), "an older kind keeps its label");
1504        assert_eq!(label("P"), Some((Route::Placebo, Label::NotArisen, true)));
1505        // Before the later change, B's remaining consequence is its own pending label.
1506        let first: String = all[..all.len() - 1].iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1507        let s = fold(&first, Summary::default());
1508        let b = s.lessons.iter().position(|x| x == "B").unwrap() as u32;
1509        let a = s.applies.iter().find(|a| a.lesson == b).unwrap();
1510        assert_eq!((a.label, a.judge, a.jv), (Label::Remains, None, 3), "a pending label keeps its judge's version");
1511        assert!(a.label.pending() && !a.label.verdict() && Label::NotArisen.verdict() && !Label::NotArisen.pending());
1512        // The session-end judge is a judge level of its own; a check's verdict keeps the level it had.
1513        let end = [&all[..], &[graded("E", "cE", true, false), ask("E", "cE"), Outcome { at: AT_END.into(), ..on(CHANGED, "E", "cE", "stop") }]].concat();
1514        let text: String = end.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1515        let s = fold(&text, Summary::default());
1516        assert!(s.judges.iter().any(|j| j.starts_with("v3|")) && s.judges.iter().any(|j| j.starts_with("v3@end|")), "{:?}", s.judges);
1517    }
1518
1519    /// An open placebo keeps its session waiting to settle when its `end` hook never ran, as an open ask
1520    /// does; its settle closes it.
1521    #[test]
1522    fn idle_sessions_wait_for_open_placebos() {
1523        let only = vec![placebo(&["A"], "p1", 0)];
1524        assert_eq!(idle_sessions(&only, &HashMap::new(), IDLE_MS + 10), ["s"]);
1525        assert!(idle_sessions(&only, &HashMap::new(), IDLE_MS - 10).is_empty(), "the placebo is the session's newest activity");
1526        let settled = [only.clone(), settle(&only, "s", 5)].concat();
1527        assert!(idle_sessions(&settled, &HashMap::new(), IDLE_MS + 10).is_empty());
1528    }
1529
1530    /// The selection summary reads a placebo's settle as its censoring (select/summary.rs `settled`), so
1531    /// the judge-missingness fit (select/policy.rs `judged`) counts the placebo as not judged instead of
1532    /// leaving it out.
1533    #[test]
1534    fn the_selection_summary_censors_a_settled_placebo() {
1535        use crate::select::summary::{Label, Summary, fold};
1536        let graded = Outcome {
1537            grades: vec![1, 1],
1538            ranks: vec![0, 1],
1539            applied: 2,
1540            channel: "stop".into(),
1541            check: "p1".into(),
1542            policy: "learned".into(),
1543            placebo: vec!["A".into(), "B".into()],
1544            ..o(GRADED, "s", &["A", "B"], 5)
1545        };
1546        let mut log = vec![graded, placebo(&["A", "B"], "p1", 10), Outcome { channel: PLACEBO.into(), check: "p1".into(), ..o(IGNORED, "s", &["A"], 15) }];
1547        log.extend(settle(&log, "s", 99));
1548        let text: String = log.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1549        let s = fold(&text, Summary::default());
1550        let label = |l: &str| s.applies.iter().find(|a| s.lessons[a.lesson as usize] == l).map(|a| a.label);
1551        assert_eq!((label("A"), label("B")), (Some(Label::Ignored), Some(Label::Censored)));
1552    }
1553
1554    #[test]
1555    fn contradicted_marks_superseded_asks_and_the_other_sides_of_a_disagreement() {
1556        let mut ask = o(ASKED, "s1", &["A", "B", "C", "D"], 10);
1557        ask.groups = vec![vec!["A".into(), "B".into()]];
1558        let all = vec![ask, o(ASKED, "s1", &["E"], 30), o(CORRECTED, "s1", &["D"], 15)];
1559        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1560        // A plain merge into C: C was asked in vain.
1561        assert_eq!(contradicted(&all, "s1", 20, Some("C"), &[]), ["C"]);
1562        // A correction that sides with A in the rendered A/B disagreement: B was wrong, A was right.
1563        assert_eq!(contradicted(&all, "s1", 20, Some("A"), &[]), ["B"]);
1564        // A new lesson superseding C and E: only C was asked before it; D is already corrected.
1565        assert_eq!(contradicted(&all, "s1", 20, None, &ids(&["C", "E", "D", "Z"])), ["C"]);
1566        assert!(contradicted(&all, "s2", 20, Some("C"), &ids(&["A"])).is_empty(), "another session asked nothing");
1567        let mut vetoed = all.clone();
1568        vetoed.push(o(VETOED, "s1", &["C"], 12));
1569        assert!(contradicted(&vetoed, "s1", 20, Some("C"), &[]).is_empty(), "a vetoed lesson already has the stronger result");
1570    }
1571
1572    #[test]
1573    fn a_placebo_or_unclear_result_is_no_result_for_any_reader() {
1574        let placebo = |kind: &str| Outcome { channel: PLACEBO.into(), ..o(kind, "s", &["A"], 5) };
1575        let unclear = Outcome { channel: "stop".into(), ..o(UNCLEAR, "s", &["A"], 5) };
1576        for x in [placebo(CHANGED), placebo(IGNORED), placebo(UNCLEAR), unclear.clone()] {
1577            assert!(!is_result(&x), "{x:?}");
1578            assert!(share(&x, "n1").is_none(), "never shared: {x:?}");
1579        }
1580        assert!(is_result(&Outcome { channel: "queued".into(), ..o(CHANGED, "s", &["A"], 5) }));
1581        assert!(!SHARED_KINDS.contains(&UNCLEAR) && !SHARED_KINDS.contains(&DELIVERY));
1582        // trust's view (`sessions`): a placebo result moves nothing, whatever its kind.
1583        let all = [placebo(CORRECTED), placebo(NOT_CORRECTED), o(NOT_CORRECTED, "t", &["A"], 6)];
1584        assert_eq!(tally(sessions(&all)["A"].values(), i64::MIN), (1, 0));
1585        // The statistics count no placebo result (`append`).
1586        let d = tmp("placebo-stats");
1587        append(&d.join("outcomes.jsonl"), &placebo(CHANGED)).unwrap();
1588        append(&d.join("outcomes.jsonl"), &unclear).unwrap();
1589        append(&d.join("outcomes.jsonl"), &Outcome { channel: "stop".into(), ..o(CHANGED, "s", &["B"], 7) }).unwrap();
1590        let kinds: Vec<String> = crate::stats::events(&d).into_iter().map(|e| e.kind).collect();
1591        assert_eq!(kinds, [crate::stats::CHANGED], "{kinds:?}");
1592        let _ = std::fs::remove_dir_all(d);
1593    }
1594
1595    #[test]
1596    fn a_vetoed_lesson_is_not_settled_and_counts_as_corrected() {
1597        let all = vec![o(ASKED, "s1", &["A", "B"], 10), o(VETOED, "s1", &["A"], 20)];
1598        assert_eq!(settle(&all, "s1", 99), vec![unjudged("s1", "B", UNJUDGED_NO_CALL)]);
1599        assert_eq!(sessions(&all)["A"]["s1"], (true, 20));
1600        assert_eq!(weights(&all)["A"], weight(0, 1));
1601    }
1602
1603    #[test]
1604    fn sessions_keep_the_earliest_result_and_tally_filters_by_it() {
1605        let all =
1606            vec![o(NOT_CORRECTED, "s1", &["A"], 10), o(CORRECTED, "s2", &["A"], 20), o(NOT_CORRECTED, "s2", &["A"], 25), o(NOT_CORRECTED, "s3", &["A"], 30)];
1607        let s = sessions(&all);
1608        assert_eq!(s["A"]["s2"], (true, 20));
1609        assert_eq!(tally(s["A"].values(), i64::MIN), (2, 1));
1610        assert_eq!(tally(s["A"].values(), 20), (1, 0));
1611    }
1612
1613    #[test]
1614    fn idle_sessions_need_old_asks_and_old_episodes() {
1615        let all = vec![o(ASKED, "old", &["A"], 0), o(ASKED, "busy", &["A"], 0), o(ASKED, "fresh", &["A"], 50), o(CORRECTED, "x", &["A"], 0)];
1616        let episodes = HashMap::from([("busy".to_string(), 60)]);
1617        assert_eq!(idle_sessions(&all, &episodes, IDLE_MS + 10), ["old"]);
1618        assert_eq!(idle_sessions(&all, &episodes, IDLE_MS + 60), ["busy", "fresh", "old"]);
1619    }
1620
1621    #[test]
1622    fn idle_sessions_skip_settled_ones() {
1623        let all = vec![
1624            o(ASKED, "done", &["A", "B"], 0),
1625            o(NOT_CORRECTED, "done", &["A"], 1),
1626            o(CORRECTED, "done", &["B"], 1),
1627            o(ASKED, "vetoed", &["A"], 0),
1628            o(VETOED, "vetoed", &["A"], 1),
1629            o(ASKED, "half", &["A", "B"], 0),
1630            o(NOT_CORRECTED, "half", &["A"], 1),
1631        ];
1632        assert_eq!(idle_sessions(&all, &HashMap::new(), IDLE_MS + 10), ["half"], "every ask of the others has its result");
1633    }
1634
1635    #[test]
1636    fn regretted_blames_asks_before_the_decision_shared_or_by_target() {
1637        let mut graded = o(GRADED, "s", &["G"], 5);
1638        graded.chosen = vec![];
1639        let log = vec![graded, o(ASKED, "s", &["B", "A"], 10), o(ASKED, "s", &["C"], 30), o(ASKED, "t", &["D"], 10)];
1640        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1641        let blame = |log: &[Outcome], target: &[String]| regretted(log, "s", 20, "R1", target, 0.5, 99, "r");
1642        let got = blame(&log, &[]);
1643        let want = |l: &str, credit: f64| Outcome { cause: "R1".into(), credit, ..o(REGRETTED, "s", &[l], 99) };
1644        assert_eq!(got, vec![want("A", 0.25), want("B", 0.25)], "shared; C was asked after the decision, G never shown");
1645        assert_eq!(blame(&log, &ids(&["B", "Z"])), vec![want("B", 0.5)], "the merge target takes it all");
1646        assert_eq!(blame(&log, &ids(&["Z"])), got, "a target not asked: shared");
1647        let mut corrected = log.clone();
1648        corrected.push(o(CORRECTED, "s", &["B"], 15));
1649        assert_eq!(blame(&corrected, &[]), vec![want("A", 0.5)], "corrected outranks regretted");
1650        let mut vetoed = log.clone();
1651        vetoed.push(o(VETOED, "s", &["B"], 15));
1652        assert_eq!(blame(&vetoed, &[]), vec![want("A", 0.5)], "vetoed outranks regretted");
1653        assert!(blame(&corrected, &ids(&["B"])).is_empty(), "the target took it as a correction: nobody else is blamed");
1654        assert!(blame(&vetoed, &ids(&["B", "Z"])).is_empty(), "the target took it as a veto: nobody else is blamed");
1655        assert_eq!(blame(&corrected, &ids(&["A", "B"])), vec![want("A", 0.5)], "the uncorrected target keeps its share");
1656        let mut again = log.clone();
1657        again.extend(got.clone());
1658        assert!(blame(&again, &[]).is_empty(), "credited once per cause");
1659        let mut half = log.clone();
1660        half.push(got[0].clone());
1661        assert_eq!(blame(&half, &[]), vec![want("B", 0.25)], "a partial run completes with the same share");
1662        assert_eq!(regretted(&again, "s", 20, "R2", &[], 0.5, 99, "r").len(), 2, "another regret blames again");
1663        assert!(regretted(&log, "u", 20, "R1", &[], 0.5, 99, "r").is_empty(), "another session asked nothing");
1664        assert!(regretted(&log, "", 20, "R1", &[], 0.5, 99, "r").is_empty());
1665        assert!(regretted(&log, "s", 0, "R1", &[], 0.5, 99, "r").is_empty(), "no decision time");
1666        assert!(regretted(&log, "s", 20, "R1", &[], 0.0, 99, "r").is_empty(), "not strong");
1667        // An old line without cause and credit still reads; a regretted line round-trips.
1668        let old: Outcome = serde_json::from_str(r#"{"kind":"asked","session":"s","lessons":["A"],"ms":1,"repo":"r"}"#).unwrap();
1669        assert_eq!(old, o(ASKED, "s", &["A"], 1));
1670        assert!(!serde_json::to_string(&old).unwrap().contains("credit"));
1671        let line = serde_json::to_string(&got[0]).unwrap();
1672        assert!(line.contains(r#""cause":"R1","credit":0.25"#), "{line}");
1673        assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), got[0]);
1674    }
1675
1676    #[test]
1677    fn a_line_from_before_propensities_reads_and_writes_back_unchanged() {
1678        let line = r#"{"kind":"asked","session":"s","lessons":["A","B"],"ms":1,"repo":"r","channel":"stop","grades":[3,1],"ranks":[0,1],"applied":2}"#;
1679        let o: Outcome = serde_json::from_str(line).unwrap();
1680        assert!(o.p.is_empty());
1681        assert_eq!(serde_json::to_string(&o).unwrap(), line);
1682        let with = Outcome { p: vec![round_p(1.0), round_p(1.0 / 3.0)], ..o };
1683        assert!(serde_json::to_string(&with).unwrap().ends_with(r#""applied":2,"p":[1.0,0.3333]}"#));
1684    }
1685
1686    #[test]
1687    fn a_graded_line_from_before_select_reads_and_writes_back_unchanged() {
1688        let line = r#"{"kind":"graded","session":"s","lessons":["A","B"],"ms":1,"repo":"r","channel":"live","grades":[3,1],"ranks":[0,1],"applied":2,"chosen":["A","B"],"explore":true,"p":[1.0,0.1]}"#;
1689        let o: Outcome = serde_json::from_str(line).unwrap();
1690        assert!(o.check.is_empty() && o.policy.is_empty() && o.free.is_empty() && o.pf.is_empty() && o.judge == 0 && !o.failed);
1691        assert!(o.nofree.is_empty() && o.tail.is_empty() && o.pt == 0.0 && o.rel.is_empty());
1692        assert_eq!(serde_json::to_string(&o).unwrap(), line);
1693        // A 0.6.0 graded line and an early delivery's from before `nofree`, `tail`, `pt` and `rel`.
1694        for line in [
1695            r#"{"kind":"graded","session":"s","lessons":["A","B","C"],"ms":1,"repo":"r","channel":"stop","grades":[2,1,1],"ranks":[0,1,2],"applied":3,"p":[0.0,0.0,0.0],"check":"0123456789ab","policy":"learned","fit":"120-ab12","seen":"9-abcdef01","source":"fixed","free":["A","B"],"pf":[1.0,1.0,0.0],"placebo":["C"],"psi":1.0,"eligible":40,"shown":30,"tokens":12000,"fill":[0,4,7],"model":"m","version":"0.6.0","dur_ms":9000,"usd":0.01}"#,
1696            r#"{"kind":"asked","session":"s","lessons":["A","B"],"ms":2,"repo":"r","channel":"early","p":[1.0,0.5],"route":"asked"}"#,
1697        ] {
1698            let o: Outcome = serde_json::from_str(line).unwrap();
1699            assert!(o.nofree.is_empty() && o.tail.is_empty() && o.pt == 0.0 && o.rel.is_empty(), "{line}");
1700            assert_eq!((o.xk, o.xs), (None, None), "{line}");
1701            assert_eq!(serde_json::to_string(&o).unwrap(), line);
1702        }
1703    }
1704
1705    #[test]
1706    fn the_selection_fields_round_trip_and_stay_local() {
1707        let g = Outcome {
1708            check: "0123456789ab".into(),
1709            policy: "learned".into(),
1710            fit: "120-ab12".into(),
1711            free: vec!["B".into()],
1712            pf: vec![0.0, 1.0, 0.0],
1713            placebo: vec!["C".into()],
1714            psi: 0.5,
1715            nofree: vec!["N9".into()],
1716            xk: Some(2),
1717            xs: Some(0),
1718            tail: vec!["T9".into()],
1719            pt: 0.125,
1720            eligible: 40,
1721            shown: 30,
1722            tokens: 12_000,
1723            fill: vec![0, 4, 7],
1724            model: "m".into(),
1725            prompt_hash: "ph".into(),
1726            harness: "claude".into(),
1727            version: "0.6.0".into(),
1728            dur_ms: 9_000,
1729            failed: true,
1730            ..o(GRADED, "s", &["A", "B", "C"], 5)
1731        };
1732        let delivered = Outcome { check: "0123456789ab".into(), route: ROUTE_DROPPED_CHARS.into(), reason: "budget".into(), ..o(ASKED, "s", &["B"], 6) };
1733        let judged = Outcome { check: "0123456789ab".into(), judge: 2, stripped: true, ..o(CHANGED, "s", &["A"], 7) };
1734        let early = Outcome { channel: "early".into(), rel: vec![0.75], ..o(ASKED, "s", &["D"], 8) };
1735        // Shareable kinds carrying every per-check field, so `share` is put to the test on each.
1736        let carried = Outcome { nofree: g.nofree.clone(), xk: g.xk, xs: g.xs, tail: g.tail.clone(), pt: g.pt, rel: vec![0.75], ..judged.clone() };
1737        for x in [&g, &delivered, &judged, &early, &carried] {
1738            let line = serde_json::to_string(x).unwrap();
1739            assert_eq!(&serde_json::from_str::<Outcome>(&line).unwrap(), x, "round trip");
1740        }
1741        // The test's s = 0 is logged, not skipped: it is a draw, not an absent field.
1742        assert!(serde_json::to_string(&g).unwrap().contains(r#""xk":2,"xs":0"#));
1743        assert!(share(&g, "n1").is_none(), "graded stays local");
1744        for x in [&delivered, &judged, &early, &carried] {
1745            let shared = serde_json::to_string(&share(x, "n1").unwrap()).unwrap();
1746            for field in
1747                ["check", "0123456789ab", "route", "reason", "judge", "stripped", "nofree", "N9", "xk", "xs", "tail", "T9", "pt", "0.125", "rel", "0.75"]
1748            {
1749                assert!(!shared.contains(field), "{field}: {shared}");
1750            }
1751        }
1752        assert!(ROUTES.iter().all(|r| !r.is_empty() && !r.contains(' ')), "one word each");
1753    }
1754
1755    #[test]
1756    fn an_older_graded_record_is_the_fixed_rule_unless_it_shows_the_cut() {
1757        let graded = |grades: &[u8], chosen: &[&str], p: &[f64]| Outcome {
1758            lessons: (0..grades.len()).map(|i| format!("L{i:02}")).collect(),
1759            grades: grades.to_vec(),
1760            chosen: chosen.iter().map(|c| c.to_string()).collect(),
1761            p: p.to_vec(),
1762            ..o(GRADED, "s", &[], 1)
1763        };
1764        // No 3: the fixed rule hands over the 2s, then the 1s, at most CAP.
1765        let soft: Vec<u8> = [vec![1; 6], vec![2; 6]].concat();
1766        let old = graded(&soft, &[], &[0.0; 12]);
1767        assert_eq!(policy_of(&old), "fixed");
1768        let want: Vec<String> = (6..12).chain(0..4).map(|i| format!("L{i:02}")).collect();
1769        assert_eq!(free_of(&old), want);
1770        // A 3: the fixed rule asks, hands over nothing.
1771        let block = graded(&[3, 1], &["L00", "L01"], &[1.0, 1.0]);
1772        assert_eq!((policy_of(&block), free_of(&block)), ("fixed", Vec::<String>::new()));
1773        // The learned cut: a propensity between 0 and 1, or an explored check; the rest go free.
1774        let cut = graded(&[3, 1, 1, 1], &["L00", "L01"], &[1.0, 1.0, 0.1, 0.1]);
1775        assert_eq!((policy_of(&cut), free_of(&cut)), ("attention", vec!["L02".to_string(), "L03".into()]));
1776        let explored = Outcome { explore: true, ..graded(&[3, 1, 1], &["L00", "L01", "L02"], &[1.0, 1.0, 1.0]) };
1777        assert_eq!((policy_of(&explored), free_of(&explored)), ("attention", Vec::<String>::new()));
1778        // A record that names its policy is taken at its word, its free list as logged.
1779        let named = Outcome { policy: "learned".into(), free: vec!["L01".into()], ..graded(&[3, 1], &["L00"], &[1.0, 0.0]) };
1780        assert_eq!((policy_of(&named), free_of(&named)), ("learned", vec!["L01".to_string()]));
1781    }
1782
1783    fn tmp(name: &str) -> std::path::PathBuf {
1784        let d = std::env::temp_dir().join(format!("borg-outcome-{name}-{}", std::process::id()));
1785        let _ = std::fs::remove_dir_all(&d);
1786        std::fs::create_dir_all(&d).unwrap();
1787        d
1788    }
1789
1790    fn lines(path: &Path, v: &[String]) {
1791        std::fs::write(path, v.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
1792    }
1793
1794    fn changed(team: &[Outcome], lesson: &str) -> u32 {
1795        let ls = [crate::conscience::Challenge { id: lesson.into(), ..Default::default() }];
1796        crate::memory::derive(team, &ls, None)[lesson].changed
1797    }
1798
1799    #[test]
1800    fn share_redacts() {
1801        let mut a = o(ASKED, "session-raw", &["A"], 5);
1802        a.repo = "secret-repo".into();
1803        a.groups = vec![vec!["A".into(), "B".into()]];
1804        a.grades = vec![3];
1805        a.ranks = vec![0];
1806        a.p = vec![0.5];
1807        let x = share(&a, "n1").unwrap();
1808        let line = serde_json::to_string(&x).unwrap();
1809        for raw in ["session-raw", "secret-repo", "groups", "grades", "ranks", "\"id\"", "version"] {
1810            assert!(!line.contains(raw), "{raw} in {line}");
1811        }
1812        assert_eq!((x.s, x.r, x.p, x.node), (tag("session-raw"), tag("secret-repo"), vec![0.5], "n1".to_string()));
1813        assert_eq!(tag("s").len(), 32);
1814        for kind in [GRADED, "veto"] {
1815            assert!(share(&o(kind, "s", &["A"], 1), "n1").is_none(), "{kind} is not shared");
1816        }
1817        let regret = share(&Outcome { cause: "episode-raw".into(), credit: 0.25, ..o(REGRETTED, "s", &["A"], 1) }, "n1").unwrap();
1818        let line = serde_json::to_string(&regret).unwrap();
1819        assert!(!line.contains("episode-raw") && line.contains(&tag("episode-raw")) && line.contains(r#""credit":0.25"#), "{line}");
1820    }
1821
1822    #[test]
1823    fn pool_fields_stay_local() {
1824        let a = Outcome { pool: vec!["A".into(), "B".into()], base: vec![1.25, 0.5], scoped: vec![true, false], p: vec![0.5], ..o(ASKED, "s", &["A"], 5) };
1825        let line = serde_json::to_string(&a).unwrap();
1826        assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), a, "round trip");
1827        let shared = serde_json::to_string(&share(&a, "n1").unwrap()).unwrap();
1828        for field in ["pool", "base", "scoped"] {
1829            assert!(line.contains(field) && !shared.contains(field), "{field}: {shared}");
1830        }
1831        assert!(!serde_json::to_string(&o(ASKED, "s", &["A"], 5)).unwrap().contains("pool"), "empty: not written");
1832    }
1833
1834    #[test]
1835    fn new_graded_fields_stay_local() {
1836        let g = Outcome {
1837            moves: vec!["hold a lock across an await".into()],
1838            whys: vec!["the send awaits under the lock".into()],
1839            xtokens: 120,
1840            line: "a2".into(),
1841            run: "async".into(),
1842            ..o(GRADED, "s", &["A"], 5)
1843        };
1844        let line = serde_json::to_string(&g).unwrap();
1845        assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), g, "round trip");
1846        for field in ["moves", "whys", "xtokens", "\"line\"", "\"run\""] {
1847            assert!(line.contains(field), "{field}: {line}");
1848        }
1849        assert!(share(&g, "n1").is_none(), "graded stays local");
1850        let plain = serde_json::to_string(&o(GRADED, "s", &["A"], 5)).unwrap();
1851        for field in ["moves", "whys", "xtokens", "\"line\"", "\"run\""] {
1852            assert!(!plain.contains(field), "empty: not written, older lines write back unchanged: {plain}");
1853        }
1854    }
1855
1856    #[test]
1857    fn publish_is_idempotent() {
1858        let home = tmp("publish");
1859        assert_eq!(publish(&home), 0, "no outcomes, nothing to publish");
1860        let mut asked = o(ASKED, "s1", &["A"], 1);
1861        asked.p = vec![1.0];
1862        lines(&home.join("outcomes.jsonl"), &[asked, o(GRADED, "s1", &["A"], 1), o(CHANGED, "s1", &["A"], 2)].map(|x| serde_json::to_string(&x).unwrap()));
1863        assert_eq!(publish(&home), 2, "graded stays local");
1864        let first = std::fs::read_to_string(home.join(TEAM_FILE)).unwrap();
1865        assert_eq!(publish(&home), 0);
1866        assert_eq!(std::fs::read_to_string(home.join(TEAM_FILE)).unwrap(), first);
1867        assert!(first.contains(r#""p":[1.0]"#) && !first.contains("\"s1\""), "{first}");
1868        // Unchanged since the last publish: the log is not read again; grown: only the new line goes.
1869        assert_eq!(std::fs::read_to_string(home.join(PUBLISHED)).unwrap(), std::fs::metadata(home.join("outcomes.jsonl")).unwrap().len().to_string());
1870        std::fs::OpenOptions::new()
1871            .append(true)
1872            .open(home.join("outcomes.jsonl"))
1873            .and_then(|mut f| std::io::Write::write_all(&mut f, format!("{}\n", serde_json::to_string(&o(IGNORED, "s2", &["A"], 3)).unwrap()).as_bytes()))
1874            .unwrap();
1875        assert_eq!(publish(&home), 1);
1876        assert_eq!(publish(&home), 0);
1877        // A team file gone (a fresh mesh) is published again in full.
1878        std::fs::remove_file(home.join(TEAM_FILE)).unwrap();
1879        assert_eq!(publish(&home), 3);
1880        let _ = std::fs::remove_dir_all(home);
1881    }
1882
1883    /// A store whose local log has s1 changed L1, and whose team file holds that result as published
1884    /// by this node, a remote session's change of L1, the same remote line from another node's copy,
1885    /// and a kind this binary does not share.
1886    fn team_store(name: &str, shuffle: bool) -> std::path::PathBuf {
1887        let home = tmp(name);
1888        let mut local = o(CHANGED, "s1", &["L1"], 10);
1889        local.repo = "app".into();
1890        lines(&home.join("outcomes.jsonl"), &[serde_json::to_string(&local).unwrap()]);
1891        let remote = |kind: &str, s: &str, node: &str| {
1892            serde_json::to_string(&Shared {
1893                kind: kind.into(),
1894                s: s.into(),
1895                lessons: vec!["L1".into()],
1896                ms: 20,
1897                node: node.into(),
1898                r: tag("web"),
1899                ..Default::default()
1900            })
1901            .unwrap()
1902        };
1903        let mut team = vec![
1904            serde_json::to_string(&share(&local, "me").unwrap()).unwrap(),
1905            remote(CHANGED, "x", "n2"),
1906            remote(CHANGED, "x", "n3"),
1907            remote("hacked", "y", "n2"),
1908            remote("veto", "z", "n2"),
1909        ];
1910        if shuffle {
1911            team.reverse();
1912            team.swap(0, 2);
1913        }
1914        lines(&home.join(TEAM_FILE), &team);
1915        home
1916    }
1917
1918    #[test]
1919    fn read_team_drops_own_copies_and_keeps_remote() {
1920        let home = team_store("team", false);
1921        let team = team_view(&home.join("lessons.jsonl"), true);
1922        assert_eq!(changed(&team, "L1"), 2, "{team:?}");
1923        assert_eq!(team.iter().filter(|o| o.session == "x").count(), 1, "one remote line per (kind, session, lessons, ms)");
1924        assert!(team.iter().all(|o| o.kind != "hacked" && o.kind != "veto"), "unshared kinds, an older peer's hive verdict among them, are ignored");
1925        assert!(team.iter().all(|o| o.repo != "app"), "local repos compare as tags");
1926        let _ = std::fs::remove_dir_all(home);
1927    }
1928
1929    #[test]
1930    fn read_team_is_order_independent() {
1931        let (a, b) = (team_store("order-a", false), team_store("order-b", true));
1932        let ls = [crate::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1933        let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None);
1934        assert_eq!(m(&a), m(&b));
1935        let _ = std::fs::remove_dir_all(a);
1936        let _ = std::fs::remove_dir_all(b);
1937    }
1938
1939    /// A node's whole fold reaches its peers: a veto that outranks the session's change, a regret's
1940    /// credit, and a session that only graded (no tick on either node). A peer holding only the team file
1941    /// derives the same memory as the node that recorded it.
1942    #[test]
1943    fn a_peer_derives_the_same_memory_from_the_team_file() {
1944        let (a, b) = (tmp("fold-a"), tmp("fold-b"));
1945        let regret = Outcome { cause: "episode-1".into(), credit: 0.5, ..o(REGRETTED, "s2", &["L1"], 40) };
1946        let log = [
1947            o(GRADED, "s0", &["L1"], 5),
1948            o(ASKED, "s1", &["L1"], 10),
1949            o(CHANGED, "s1", &["L1"], 20),
1950            o(VETOED, "s1", &["L1"], 30),
1951            o(ASKED, "s2", &["L1"], 35),
1952            regret,
1953        ];
1954        lines(&a.join("outcomes.jsonl"), &log.map(|x| serde_json::to_string(&x).unwrap()));
1955        publish(&a);
1956        std::fs::copy(a.join(TEAM_FILE), b.join(TEAM_FILE)).unwrap();
1957        let ls = [crate::conscience::Challenge { id: "L1".into(), origin_ms: 1, ..Default::default() }];
1958        let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None);
1959        let (here, there) = (m(&a), m(&b));
1960        assert_eq!((here["L1"].vetoed, here["L1"].changed, here["L1"].regretted), (1, 0, 0.5), "{here:?}");
1961        assert_eq!(here, there);
1962        let _ = std::fs::remove_dir_all(a);
1963        let _ = std::fs::remove_dir_all(b);
1964    }
1965
1966    /// The unguarded surface `SHARED_KINDS` documents, pinned until the deferred guards land: a remote
1967    /// line under one of this node's session tags is dropped here, whatever its kind, while a peer
1968    /// without that session takes it, and the stronger result wins there.
1969    #[test]
1970    fn a_remote_line_under_a_local_tag_counts_on_peers_only() {
1971        let (victim, peer) = (tmp("forged-victim"), tmp("forged-peer"));
1972        let own = o(CHANGED, "s1", &["L1"], 10);
1973        let forged = Shared { kind: CORRECTED.into(), s: tag("s1"), lessons: vec!["L1".into()], ms: 11, node: "n2".into(), ..Default::default() };
1974        let team = [serde_json::to_string(&share(&own, "me").unwrap()).unwrap(), serde_json::to_string(&forged).unwrap()];
1975        lines(&victim.join("outcomes.jsonl"), &[serde_json::to_string(&own).unwrap()]);
1976        lines(&victim.join(TEAM_FILE), &team);
1977        lines(&peer.join(TEAM_FILE), &team);
1978        let ls = [crate::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1979        let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None).remove("L1").unwrap();
1980        assert_eq!((m(&victim).changed, m(&victim).corrected), (1, 0), "the victim never sees the override");
1981        assert_eq!((m(&peer).changed, m(&peer).corrected), (0, 1), "a peer counts the forged correction");
1982        let _ = std::fs::remove_dir_all(victim);
1983        let _ = std::fs::remove_dir_all(peer);
1984    }
1985
1986    /// A peer's `tested` line counts for a lesson its session's shared records name, and for no other.
1987    #[test]
1988    fn a_peer_green_run_counts_only_for_lessons_its_session_was_given() {
1989        let home = tmp("tested-team");
1990        let asked = share(&o(ASKED, "p1", &["L1"], 10), "peer").unwrap();
1991        let ok = share(&o(TESTED, "p1", &["L1"], 20), "peer").unwrap();
1992        let stray = share(&o(TESTED, "p1", &["L2"], 20), "peer").unwrap();
1993        let alone = share(&o(TESTED, "p2", &["L1"], 20), "peer").unwrap();
1994        // A 0.3.0 peer's hive verdict is not shared any more, so it gives its session nothing.
1995        let verdict = Shared { kind: "verified".into(), ..share(&o(ASKED, "p3", &["L1"], 10), "peer").unwrap() };
1996        let unbacked = share(&o(TESTED, "p3", &["L1"], 20), "peer").unwrap();
1997        let all = [&asked, &ok, &stray, &alone, &verdict, &unbacked];
1998        lines(&home.join(TEAM_FILE), &all.map(|x| serde_json::to_string(x).unwrap()));
1999        let ls = ["L1", "L2"].map(|id| crate::conscience::Challenge { id: id.into(), ..Default::default() });
2000        let m = crate::memory::derive(&team_view(&home.join("lessons.jsonl"), true), &ls, None);
2001        assert_eq!((m["L1"].tested, m["L2"].tested), (1, 0), "{m:?}");
2002        let _ = std::fs::remove_dir_all(home);
2003    }
2004
2005    #[test]
2006    fn off_keeps_beliefs_local() {
2007        let home = team_store("off", false);
2008        let local = team_view(&home.join("lessons.jsonl"), false);
2009        assert_eq!(local, read(&home.join("outcomes.jsonl")));
2010        assert_eq!(changed(&local, "L1"), 1);
2011        let _ = std::fs::remove_dir_all(home);
2012    }
2013
2014    /// Several writers appending to one log at once (several drones on one store): every record
2015    /// reads back, none torn into `{A}{B}\n\n`.
2016    #[test]
2017    fn concurrent_appends_keep_every_record() {
2018        let home = tmp("concurrent");
2019        let p = home.join("outcomes.jsonl");
2020        let (writers, each) = (8, 2000);
2021        std::thread::scope(|scope| {
2022            for w in 0..writers {
2023                let p = &p;
2024                scope.spawn(move || (0..each).for_each(|i| append(p, &o(ASKED, &format!("s{w}"), &["A"], i)).unwrap()));
2025            }
2026        });
2027        assert_eq!(read(&p).len(), writers * each as usize);
2028        let _ = std::fs::remove_dir_all(home);
2029    }
2030
2031    #[test]
2032    fn append_and_read_round_trip() {
2033        let dir = std::env::temp_dir().join(format!("borg-outcome-{}", std::process::id()));
2034        let p = dir.join("outcomes.jsonl");
2035        let _ = std::fs::remove_file(&p);
2036        append(&p, &o(ASKED, "s", &["A"], 1)).unwrap();
2037        append(&p, &o(CORRECTED, "s", &["A"], 2)).unwrap();
2038        assert_eq!(read(&p), vec![o(ASKED, "s", &["A"], 1), o(CORRECTED, "s", &["A"], 2)]);
2039        let _ = std::fs::remove_dir_all(dir);
2040    }
2041}