Skip to main content

mecha_core/
candidate.rs

1//! A proposed harness change, and the decision about it.
2//!
3//! This is the gate `docs/SELF-IMPROVEMENT-RESEARCH.md` §13.3 specifies, and
4//! it is pure on purpose: the arms are run elsewhere, and what arrives here is
5//! two sets of [`RunStats`] plus the prediction that was made before either
6//! was measured. Getting this wrong is silent — a bad rule that scores well
7//! ships and rides in every future prompt — so it is the part that gets unit
8//! tests rather than a live trial.
9//!
10//! ## The shape
11//!
12//! A candidate carries a **falsifiable prediction** (AHE's decision
13//! observability): the metric it claims to move and the direction. Without
14//! one, a proposal cannot be refuted by the next measurement, and
15//! "harness updating is not harness benefit" is what follows — agents
16//! modifying themselves with no corresponding gain.
17//!
18//! ## Why paired, and why a holdout
19//!
20//! Episodes differ from each other far more than arms differ from each other,
21//! so an unpaired comparison measures which episodes landed in which arm.
22//! Pairing by episode removes that. And selecting among candidates on the same
23//! episodes that justify the winner is a multiple-comparisons trap: the more
24//! candidates, the better the winner looks and the less of it is real. So the
25//! corpus is split deterministically, selection happens on one slice, and the
26//! winner is confirmed on a slice never used for selection.
27//!
28//! ## Why counts rather than a significance test
29//!
30//! Deliberate. With a few dozen episodes the noise is the model's sampling,
31//! not the measurement, and the answer to sampling noise is repetition
32//! (`--runs k`, pass^k) rather than a p-value over one sample. A test here
33//! would put a number on the wrong uncertainty and read as rigour. The raw
34//! win/loss/tie counts are reported instead, so a human reading a proposal
35//! sees what the decision was made from.
36
37use crate::session::RunStats;
38use std::collections::BTreeMap;
39
40/// What a candidate claims it will do.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Metric {
44    /// Runs that finished with their last tool call failed.
45    EndedOnFailedCall,
46    /// Share of attempted tool calls the environment refused.
47    ToolErrorRate,
48    /// Runs the harness cut short rather than the model finishing.
49    CutShort,
50    /// Summaries taken. Fewer is better only when the work is unchanged,
51    /// which is what the work guardrail is for.
52    Compactions,
53    /// Turns spent.
54    Turns,
55    /// Arguments the model produced that did not parse.
56    MalformedArgs,
57}
58
59impl Metric {
60    /// Every metric a proposal may name.
61    ///
62    /// The list exists so the diagnostic brief and the diagnostic instruction
63    /// cannot disagree about it. Until it did, `DIAGNOSE_INSTRUCTION` offered
64    /// six metrics to predict while `Evidence::brief` reported values for
65    /// three, and the nightly's two worst proposals were both on metrics whose
66    /// value it had never been shown — one of them `cut_short`, on a corpus
67    /// where `cut_short` was zero and the measurement could only tie.
68    pub const ALL: [Metric; 6] = [
69        Metric::EndedOnFailedCall,
70        Metric::ToolErrorRate,
71        Metric::CutShort,
72        Metric::Compactions,
73        Metric::Turns,
74        Metric::MalformedArgs,
75    ];
76
77    /// The name the proposal block uses, which is the serde spelling.
78    pub fn as_str(&self) -> &'static str {
79        match self {
80            Metric::EndedOnFailedCall => "ended_on_failed_call",
81            Metric::ToolErrorRate => "tool_error_rate",
82            Metric::CutShort => "cut_short",
83            Metric::Compactions => "compactions",
84            Metric::Turns => "turns",
85            Metric::MalformedArgs => "malformed_args",
86        }
87    }
88
89    /// How much this episode can say about the metric, higher being more.
90    ///
91    /// **The priority for a prioritised replay draw, and it needs no new
92    /// concept: it is the metric's own value on the recorded run.** Every
93    /// metric here is a cost, so an episode already at zero has no room to
94    /// improve — whatever the change does, that pair can only tie or worsen,
95    /// and it costs a real model run per arm to learn that. An episode with a
96    /// high recorded cost is the one that can discriminate.
97    ///
98    /// This is prioritised experience replay's shape with the sensor that
99    /// exists today. PER samples by |TD error| because a surprising transition
100    /// carries the most information; here the same argument is made with
101    /// headroom, because the appraisal record that would supply a goal error
102    /// is not built yet. When it is, |goal error| joins this rather than
103    /// replacing it — a run can be uninformative about a metric and still be
104    /// the most instructive thing that happened all week.
105    ///
106    /// **It is only ever a priority, never a score.** Drawing the *selection*
107    /// slice this way is safe precisely because selection only picks; the
108    /// holdout, drawn uniformly, is what confirms. See [`judge_drawn`].
109    pub fn headroom(&self, recorded: &RunStats) -> f64 {
110        self.of(recorded)
111    }
112
113    /// The metric's value for one run. Lower is better for every metric here,
114    /// which is a deliberate constraint rather than a coincidence: a mixed
115    /// polarity is the kind of thing that inverts a comparison silently, so
116    /// anything worth predicting gets phrased as a cost.
117    pub fn of(&self, s: &RunStats) -> f64 {
118        match self {
119            Metric::EndedOnFailedCall => f64::from(u8::from(s.ended_on_failed_call)),
120            Metric::ToolErrorRate => {
121                if s.tool_calls == 0 {
122                    // No calls is no evidence, not a clean record. Neutral,
123                    // so an episode that made no calls in either arm cannot
124                    // be counted as a win by a change that suppressed work.
125                    0.0
126                } else {
127                    f64::from(s.tool_errors) / f64::from(s.tool_calls)
128                }
129            }
130            // The harness ending the run, not a person cancelling it — the
131            // same predicate `doctor` reads. Counting `Interrupted` here made
132            // a cancelled arm a loss on the metric it was predicting.
133            Metric::CutShort => f64::from(u8::from(s.stop_cause.is_some_and(|c| c.cut_short()))),
134            Metric::Compactions => f64::from(s.compactions),
135            Metric::Turns => f64::from(s.turns),
136            Metric::MalformedArgs => f64::from(s.malformed_tool_args),
137        }
138    }
139}
140
141/// The claim a candidate is judged against, made before the measurement.
142#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
143pub struct Prediction {
144    pub metric: Metric,
145    /// Free text from the diagnostician: what it thinks is wrong and why this
146    /// change addresses it. Recorded for the human who reads the proposal —
147    /// never parsed, and never consulted by the decision.
148    pub rationale: String,
149}
150
151/// What kind of change this is, which decides how far it can get without a
152/// person. See §13.2–13.3 of the research.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum ChangeClass {
156    /// A reversible configuration value.
157    Config,
158    /// Text entering the system prompt.
159    Prose,
160    /// A new hook, subagent, trigger, eval case, tool surface, or source
161    /// change. Always a human's call.
162    Architecture,
163    /// The interlock, the path jail, sandbox configuration, outbox routing.
164    /// Human-gated, and the standing recommendation is that these are never
165    /// proposed at all: a loop that can argue for widening its own
166    /// confinement will eventually argue well, and the metric agrees with it
167    /// — a run that can reach the network fails fewer calls.
168    Security,
169}
170
171impl ChangeClass {
172    /// Whether measurement alone can accept this class.
173    fn auto_acceptable(&self) -> bool {
174        matches!(self, ChangeClass::Config | ChangeClass::Prose)
175    }
176}
177
178/// One episode measured in both arms. Paired by `episode`, which is a replay
179/// corpus id — a session id, or an eval case id.
180#[derive(Debug, Clone)]
181pub struct Pair {
182    pub episode: String,
183    pub baseline: RunStats,
184    pub candidate: RunStats,
185}
186
187/// How one slice of the corpus came out.
188#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
189pub struct Tally {
190    pub wins: usize,
191    pub losses: usize,
192    pub ties: usize,
193}
194
195impl Tally {
196    pub fn total(&self) -> usize {
197        self.wins + self.losses + self.ties
198    }
199    fn better(&self) -> bool {
200        self.wins > self.losses
201    }
202    fn not_worse(&self) -> bool {
203        self.wins >= self.losses
204    }
205}
206
207/// What the gate decided, and why in words a human can check.
208#[derive(Debug, Clone, PartialEq, serde::Serialize)]
209pub enum Disposition {
210    /// Measurement carried it: nothing further needed.
211    Accept,
212    /// Measured well but the class requires a person, or the evidence is thin.
213    Propose(String),
214    /// Measured badly, or a guardrail moved.
215    Reject(String),
216}
217
218/// The full result of grading a candidate, kept whole so a proposal records
219/// what it was decided from rather than only the verdict.
220#[derive(Debug, Clone, serde::Serialize)]
221pub struct Judgement {
222    pub disposition: Disposition,
223    pub selection: Tally,
224    pub holdout: Tally,
225    /// Tool calls attempted across each arm — the work guardrail. A change
226    /// that improves its metric by attempting less has not improved anything.
227    pub work_baseline: u64,
228    pub work_candidate: u64,
229}
230
231/// Below this many paired episodes in a slice, a difference is not evidence.
232///
233/// Eight and four, which are small — the constraint is that a replay corpus
234/// costs a real model run per episode per arm, so a floor set where the
235/// statistics would like it is a floor that stops the loop running at all.
236/// The holdout is doing the work that a larger sample would; these numbers
237/// only stop a two-episode coincidence being called a result.
238pub const MIN_SELECTION_PAIRS: usize = 8;
239pub const MIN_HOLDOUT_PAIRS: usize = 4;
240
241/// How many held-out episodes must have had **room to move** before the
242/// holdout is allowed to confirm anything.
243///
244/// **An episode whose baseline cost is already zero cannot produce a win.** It
245/// can still produce a loss, so an all-zero holdout is a real regression check
246/// — but it is not confirmation, and `not_worse` cannot tell the two apart:
247/// `0 wins, 0 losses, 4 ties` satisfies `wins >= losses` and reads as
248/// "confirmed on unseen work".
249///
250/// That is not hypothetical here. The holdout is drawn *uniformly* on purpose,
251/// and on 2026-08-31 the live corpus was 69% runs of two turns or fewer, with
252/// four of the six metrics at zero across all 172 runs. A uniform draw of four
253/// from that pool is usually four episodes that could not have moved whatever
254/// was predicted. So the gate's strongest claim was the one its evidence was
255/// least able to support.
256///
257/// Set equal to [`MIN_HOLDOUT_PAIRS`] rather than below it: a slice that
258/// cannot confirm is not a smaller confirmation, and the disposition for
259/// "nothing has confirmed this" already exists and is [`Disposition::Propose`]
260/// — it reaches a person instead of being believed.
261pub const MIN_INFORMATIVE_HOLDOUT: usize = MIN_HOLDOUT_PAIRS;
262
263/// How far a metric the candidate did *not* predict may worsen before the
264/// measured win is treated as bought rather than earned.
265///
266/// The work guardrail below counts tool calls, which catches a gain bought by
267/// attempting less. It does not catch a gain bought by failing more: nothing
268/// stopped a change that halved `turns` while doubling `tool_error_rate`, and
269/// `turns` is currently the only metric in this corpus with real headroom, so
270/// that is the trade the loop is most likely to be offered.
271///
272/// This is the regression-awareness that GRASP (arXiv:2605.29668) names as the
273/// difference between a self-improvement loop that compounds and one that
274/// accumulates: a candidate is accepted on one number and the others are never
275/// looked at, so each accepted change silently pays for itself somewhere else.
276/// A cliff rather than a ratchet, like [`WORK_FLOOR`] — some movement is noise.
277pub const REGRESSION_CEILING: f64 = 1.25;
278
279/// The smallest corpus a measurement can be drawn from.
280///
281/// Both slices come off the same pool, so a corpus below their sum cannot fill
282/// them however it is split. **A necessary condition and not a sufficient
283/// one** — these are recorded runs, and the eligible pool is the replayable
284/// subset of them, which is smaller — and a caller must say which of the two
285/// it is claiming when it reports the refusal.
286///
287/// It gates the *measurement*, never the diagnosis. A `Prose`, `Architecture`
288/// or `Security` proposal is staged for a person and never touches a replay,
289/// so a small corpus must not withhold those: found in review, where an early
290/// return skipped the entire night and `--from-workspace` made a sub-floor
291/// corpus easy to reach on purpose.
292pub const MIN_MEASURABLE_RUNS: usize = MIN_SELECTION_PAIRS + MIN_HOLDOUT_PAIRS;
293
294/// Whether a corpus of this many runs could fill both slices. See
295/// [`MIN_MEASURABLE_RUNS`].
296pub fn measurable(runs: usize) -> bool {
297    runs >= MIN_MEASURABLE_RUNS
298}
299
300/// How far work may fall before a gain is treated as bought rather than
301/// earned. Some drop is legitimate — a change that stops a redundant re-read
302/// does less work and is better for it — so this is a cliff, not a ratchet.
303pub const WORK_FLOOR: f64 = 0.75;
304
305/// Split an episode into selection or holdout, deterministically.
306///
307/// By id hash rather than at random: the same corpus must split the same way
308/// every time or a rerun silently grades a candidate against a different
309/// holdout, and "confirmed on unseen episodes" stops meaning anything. Pure,
310/// so the split is unit-testable.
311pub fn is_holdout(episode: &str, holdout_in: u64) -> bool {
312    // FNV-1a, written out rather than `DefaultHasher`. std explicitly does not
313    // guarantee `DefaultHasher`'s algorithm across releases, so a toolchain
314    // upgrade would re-partition selection and holdout with nothing visible
315    // changing — and "confirmed on episodes it was never chosen on" would
316    // quietly stop being true. The invariant this function exists for is
317    // stability, so the hash has to be one this file owns.
318    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
319    const PRIME: u64 = 0x100_0000_01b3;
320    let mut h = OFFSET;
321    for byte in episode.as_bytes() {
322        h ^= u64::from(*byte);
323        h = h.wrapping_mul(PRIME);
324    }
325    h.is_multiple_of(holdout_in)
326}
327
328/// Grade a candidate against its own prediction.
329/// Reject an accepted candidate that paid for its win on a metric it never
330/// predicted.
331///
332/// Applied only to an `Accept`, and only here rather than inside
333/// [`judge_slices`], because it is the one thing that needs [`RunStats`]: the
334/// generic gate sees one cost function and cannot ask about the metrics it was
335/// not given, and `mecha eval --ab-config` grades cases rather than runs so it
336/// has no `RunStats` to ask about.
337///
338/// **This is not a claim that the eval rig is untouched by this change as a
339/// whole.** `MIN_INFORMATIVE_HOLDOUT` *is* inside [`judge_slices`], so eval's
340/// A/B arm gained that check too — deliberately, since a held-out case with no
341/// cost to lose confirms exactly as little there as here, but the two are
342/// separate decisions and only this one was scoped to runs.
343///
344/// A cost appearing from *nothing* is treated differently from one that grew:
345/// it **proposes** rather than rejects. `compactions`, `malformed_args` and
346/// `ended_on_failed_call` are all zero across the live corpus, so "0 → 2" is
347/// not noise on a large number — but neither is it always bad, and this
348/// function cannot tell which. A `compact_at_tokens` low enough to compact is
349/// a change whose whole purpose is to move `compactions` off zero; rejecting
350/// it would make one of the four overridable knobs unusable by construction.
351/// See the branch itself.
352fn guard_regressions<'a>(
353    j: Judgement,
354    predicted: Metric,
355    pairs: impl Iterator<Item = &'a Pair> + Clone,
356) -> Judgement {
357    if j.disposition != Disposition::Accept {
358        return j;
359    }
360    // **Every metric is examined before anything is returned, because the two
361    // outcomes are not equally serious and they were racing on array order.**
362    // A `0 → nonzero` proposal returned immediately, so a genuine ratio breach
363    // on a metric later in `Metric::ALL` was never reached: the reviewer got
364    // the benign "a cost that was not there before, and only a person can tell
365    // which" and never saw that something else had also doubled. `Compactions`
366    // sits at index 3 and `MalformedArgs` at 5, so the pair that hides the
367    // worse finding behind the milder one is reachable rather than theoretical.
368    //
369    // A `Reject` still returns as soon as it is found — it is the strongest
370    // verdict available and nothing later can outrank it. Only the `Propose`
371    // waits.
372    let mut appeared: Option<String> = None;
373    for metric in Metric::ALL {
374        if metric == predicted {
375            continue;
376        }
377        let total = |pick: fn(&Pair) -> &RunStats| -> f64 {
378            pairs.clone().map(|p| metric.of(pick(p))).sum()
379        };
380        let (before, after) = (total(|p| &p.baseline), total(|p| &p.candidate));
381        if before > 0.0 {
382            if after > before * REGRESSION_CEILING {
383                return Judgement {
384                    disposition: Disposition::Reject(format!(
385                        "predicted a lower {predicted:?} and got one, but {metric:?} rose from \
386                         {before:.2} to {after:.2} across the same episodes: a win paid for on \
387                         a metric nobody was watching is not a win"
388                    )),
389                    ..j
390                };
391            }
392        } else if after > 0.0 && appeared.is_none() {
393            // **A cost appearing from nothing reaches a person; it does not
394            // auto-reject.** A first version rejected it outright, on the
395            // reasoning that a failure mode that was not there before is not
396            // noise on a large number. True, and it foreclosed the closed
397            // override set's own purpose: `compactions` is zero across this
398            // corpus, so *any* `compact_at_tokens` low enough to actually
399            // compact scored a hard rejection on the metric that knob exists
400            // to move. The guard would have made one of four knobs unusable
401            // and said nothing about why.
402            //
403            // `Propose` is the honest disposition and the one this design
404            // already has for it. Nothing here can tell "compaction started
405            // happening, which is the point" from "malformed arguments
406            // appeared, which is not" — and a reader can. So it never
407            // auto-accepts, and it never silently refuses either.
408            appeared = Some(format!(
409                "predicted a lower {predicted:?} and got one, but {metric:?} rose from \
410                 nothing to {after:.2} across the same episodes — a cost that was not there \
411                 before. That is the intended effect for some changes and a regression for \
412                 others, and only a person can tell which"
413            ));
414        }
415    }
416    match appeared {
417        Some(why) => Judgement {
418            disposition: Disposition::Propose(why),
419            ..j
420        },
421        None => j,
422    }
423}
424
425pub fn judge(
426    class: ChangeClass,
427    prediction: &Prediction,
428    pairs: &[Pair],
429    holdout_in: u64,
430) -> Judgement {
431    let metric = prediction.metric;
432    let judged = judge_with(
433        class,
434        pairs,
435        |p| {
436            (
437                p.episode.as_str(),
438                metric.of(&p.baseline),
439                metric.of(&p.candidate),
440            )
441        },
442        |p| {
443            (
444                u64::from(p.baseline.tool_calls),
445                u64::from(p.candidate.tool_calls),
446            )
447        },
448        holdout_in,
449    );
450    guard_regressions(judged, metric, pairs.iter())
451}
452
453/// Judge two slices the caller drew: selection by priority, holdout uniformly.
454///
455/// The replay path's entry point. `judge` hash-partitions one pool and is
456/// still right for `eval --ab-config`, where every case runs and the pool is
457/// therefore already uniform. A replay corpus is *sampled*, and once it is
458/// sampled by informativeness the partition inherits the bias — see
459/// [`judge_slices`].
460pub fn judge_drawn(
461    class: ChangeClass,
462    prediction: &Prediction,
463    selection: &[Pair],
464    holdout: &[Pair],
465) -> Judgement {
466    let metric = prediction.metric;
467    let sel: Vec<&Pair> = selection.iter().collect();
468    let hold: Vec<&Pair> = holdout.iter().collect();
469    let judged = judge_slices(
470        class,
471        &sel,
472        &hold,
473        // Inline rather than bound: a named closure here cannot be inferred
474        // as higher-ranked over the borrow, the same reason `judge` spells
475        // these out at the call.
476        |p| {
477            (
478                p.episode.as_str(),
479                metric.of(&p.baseline),
480                metric.of(&p.candidate),
481            )
482        },
483        |p| {
484            (
485                u64::from(p.baseline.tool_calls),
486                u64::from(p.candidate.tool_calls),
487            )
488        },
489    );
490    guard_regressions(judged, metric, selection.iter().chain(holdout.iter()))
491}
492
493/// The same gate over anything that can name an episode and produce a cost.
494///
495/// Two currencies grade a candidate here and they are not interchangeable.
496/// Replayed sessions are scored on [`RunStats`] — did the *harness* go better
497/// — while eval cases are scored on whether the case **passed**, which is the
498/// content-sensitive arm a prose change needs, because replay holds tool
499/// results fixed and cannot see a change in what the model actually said. One
500/// gate, so the guardrails and the holdout cannot drift apart between them.
501///
502/// `cost` returns `(episode, baseline, candidate)` and lower must be better,
503/// as in [`Metric`]. `work` returns the two arms' work volume for the Goodhart
504/// guardrail.
505pub fn judge_with<T>(
506    class: ChangeClass,
507    pairs: &[T],
508    cost: impl for<'a> Fn(&'a T) -> (&'a str, f64, f64),
509    work: impl Fn(&T) -> (u64, u64),
510    holdout_in: u64,
511) -> Judgement {
512    let (holdout, selection): (Vec<&T>, Vec<&T>) = pairs
513        .iter()
514        .partition(|p| is_holdout(cost(p).0, holdout_in));
515    judge_slices(class, &selection, &holdout, cost, work)
516}
517
518/// The gate over two slices the caller drew itself.
519///
520/// **Extracted because prioritising a corpus prioritises both halves of a
521/// partition of it.** [`is_holdout`] splits one pool, which is right when the
522/// pool was gathered uniformly — every eval case runs, so `--ab-config` still
523/// uses it. It is wrong the moment the pool is drawn by informativeness:
524/// hashing a biased pool yields two biased slices, and the holdout stops being
525/// the thing that corrects the selection's bias. Prioritised experience replay
526/// has the same problem and answers it with importance weights; here the
527/// answer is that the two slices are **drawn separately** — the holdout
528/// uniformly, the selection by [`Metric::headroom`] — and this function's job
529/// is to score whatever it is handed rather than to decide what goes where.
530pub fn judge_slices<T>(
531    class: ChangeClass,
532    selection: &[&T],
533    holdout: &[&T],
534    cost: impl for<'a> Fn(&'a T) -> (&'a str, f64, f64),
535    work: impl Fn(&T) -> (u64, u64),
536) -> Judgement {
537    let (selection, holdout) = (selection.to_vec(), holdout.to_vec());
538    let tally = |slice: &[&T]| {
539        let mut t = Tally::default();
540        for p in slice {
541            let (_, before, after) = cost(p);
542            // Every metric is a cost, so down is a win.
543            match after.partial_cmp(&before) {
544                Some(std::cmp::Ordering::Less) => t.wins += 1,
545                Some(std::cmp::Ordering::Greater) => t.losses += 1,
546                _ => t.ties += 1,
547            }
548        }
549        t
550    };
551    let sel = tally(&selection);
552    let hold = tally(&holdout);
553
554    let sum = |slice: &[&T], pick: fn((u64, u64)) -> u64| -> u64 {
555        slice.iter().map(|p| pick(work(p))).sum()
556    };
557    let work_baseline = sum(&selection, |(b, _)| b) + sum(&holdout, |(b, _)| b);
558    let work_candidate = sum(&selection, |(_, c)| c) + sum(&holdout, |(_, c)| c);
559
560    let judgement = |disposition| Judgement {
561        disposition,
562        selection: sel.clone(),
563        holdout: hold.clone(),
564        work_baseline,
565        work_candidate,
566    };
567
568    // Order matters: a guardrail breach is a rejection whatever the score, and
569    // thin evidence is not a rejection — it is an absence of one.
570    if work_baseline > 0 && (work_candidate as f64) < work_baseline as f64 * WORK_FLOOR {
571        return judgement(Disposition::Reject(format!(
572            "work fell from {work_baseline} tool calls to {work_candidate}: a gain bought by \
573             attempting less is not a gain"
574        )));
575    }
576    if sel.total() < MIN_SELECTION_PAIRS {
577        return judgement(Disposition::Propose(format!(
578            "only {} paired episode(s) in the selection slice, below the floor of \
579             {MIN_SELECTION_PAIRS} — read it rather than trusting it",
580            sel.total()
581        )));
582    }
583    if !sel.better() {
584        return judgement(Disposition::Reject(format!(
585            "did not beat the original: {} better, {} worse, {} unchanged",
586            sel.wins, sel.losses, sel.ties
587        )));
588    }
589    if hold.total() < MIN_HOLDOUT_PAIRS {
590        return judgement(Disposition::Propose(format!(
591            "won on the selection slice but the holdout has only {} episode(s), below \
592             {MIN_HOLDOUT_PAIRS} — nothing has confirmed it on unseen work",
593            hold.total()
594        )));
595    }
596    if !hold.not_worse() {
597        return judgement(Disposition::Reject(format!(
598            "won on selection and lost on the holdout ({} better, {} worse): the gain did not \
599             survive episodes it was not chosen on",
600            hold.wins, hold.losses
601        )));
602    }
603    // Only now, with the regression check already passed. A holdout that
604    // *found* a loss has spoken, whatever its headroom — ordering this ahead
605    // of `not_worse` turned a detected regression into "thin evidence", which
606    // is the opposite finding, and the overfitting test caught it.
607    //
608    // What is left here is the all-ties case, where sample size and
609    // discriminating power come apart. See `MIN_INFORMATIVE_HOLDOUT`.
610    let informative = holdout
611        .iter()
612        .filter(|p| {
613            let (_, before, _) = cost(p);
614            before > 0.0
615        })
616        .count();
617    if informative < MIN_INFORMATIVE_HOLDOUT {
618        return judgement(Disposition::Propose(format!(
619            "won on the selection slice, and nothing got worse on the holdout — but only \
620             {informative} of {} held-out episode(s) had any of this metric to begin with, \
621             so the holdout ruled out a regression without ever being able to confirm a gain",
622            hold.total()
623        )));
624    }
625    if !class.auto_acceptable() {
626        return judgement(Disposition::Propose(format!(
627            "measured better, but a {class:?} change is a person's decision however it scored"
628        )));
629    }
630    judgement(Disposition::Accept)
631}
632
633/// Pair two arms by episode id, dropping anything that ran in only one.
634///
635/// An episode missing from an arm is not a tie and not a loss — it is missing,
636/// and scoring it either way would let a candidate that *crashes* on hard
637/// episodes look good on the ones it survived.
638pub fn pair_arms(
639    baseline: &BTreeMap<String, RunStats>,
640    candidate: &BTreeMap<String, RunStats>,
641) -> Vec<Pair> {
642    baseline
643        .iter()
644        .filter_map(|(episode, b)| {
645            candidate.get(episode).map(|c| Pair {
646                episode: episode.clone(),
647                baseline: b.clone(),
648                candidate: c.clone(),
649            })
650        })
651        .collect()
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn a_corpus_that_cannot_fill_both_slices_cannot_measure() {
660        // The floor is the sum, not either half: both slices are drawn from
661        // one pool, so a corpus between the two still cannot fill them.
662        assert!(!measurable(0));
663        assert!(!measurable(MIN_SELECTION_PAIRS));
664        assert!(!measurable(MIN_MEASURABLE_RUNS - 1));
665        assert!(measurable(MIN_MEASURABLE_RUNS));
666        // The live case that motivated it: the morning-briefing job has 11
667        // recorded runs against a floor of 12.
668        assert!(!measurable(11));
669        assert!(measurable(236));
670    }
671
672    #[test]
673    fn every_metric_variant_reaches_all() {
674        // `ALL` drives the brief, `guard_regressions`, and both consistency
675        // tests below — which is why it cannot police itself: a seventh
676        // variant that never joined this array would silently drop out of all
677        // four, and every test that iterates `ALL` would keep passing while
678        // covering less.
679        //
680        // The match is exhaustive, so adding a variant stops the build here
681        // and the count names the fix. That is the whole guard: the compiler
682        // points, the assertion says what to do.
683        for m in Metric::ALL {
684            match m {
685                Metric::EndedOnFailedCall
686                | Metric::ToolErrorRate
687                | Metric::CutShort
688                | Metric::Compactions
689                | Metric::Turns
690                | Metric::MalformedArgs => {}
691            }
692        }
693        assert_eq!(
694            Metric::ALL.len(),
695            6,
696            "a Metric variant was added or removed without updating ALL — the brief, \
697             guard_regressions and the drift tests all read it"
698        );
699    }
700
701    #[test]
702    fn every_metric_name_is_its_serde_spelling() {
703        // `as_str` is what the brief prints and what the proposal block is
704        // parsed against, and serde is what the candidate store round-trips
705        // through. A metric whose two spellings disagree would be reported
706        // under one name and proposable only under the other.
707        for m in Metric::ALL {
708            let wire = serde_json::to_string(&m).unwrap();
709            assert_eq!(wire.trim_matches('"'), m.as_str());
710        }
711    }
712
713    #[test]
714    fn a_metric_no_run_has_any_of_is_visible_as_zero_headroom() {
715        // The 2026-08-28 nightly in shape: it predicted a lower `cut_short`
716        // over a corpus where every run was `Completed` or `Interrupted`, and
717        // `cut_short` excludes `Interrupted` on purpose. Every pair could only
718        // tie, so the measurement it would have cost could not have informed
719        // anything.
720        let completed = RunStats {
721            stop_cause: Some(crate::agent::StopCause::Completed),
722            ..Default::default()
723        };
724        let interrupted = RunStats {
725            stop_cause: Some(crate::agent::StopCause::Interrupted),
726            ..Default::default()
727        };
728        assert_eq!(Metric::CutShort.of(&completed), 0.0);
729        assert_eq!(Metric::CutShort.of(&interrupted), 0.0);
730        let cut = RunStats {
731            stop_cause: Some(crate::agent::StopCause::MaxTurns),
732            ..Default::default()
733        };
734        assert_eq!(Metric::CutShort.of(&cut), 1.0);
735    }
736
737    use crate::agent::StopCause;
738
739    fn run(calls: u32, errors: u32, ended_failed: bool) -> RunStats {
740        RunStats {
741            tool_calls: calls,
742            tool_errors: errors,
743            ended_on_failed_call: ended_failed,
744            stop_cause: Some(StopCause::Completed),
745            ..RunStats::default()
746        }
747    }
748
749    fn prediction(metric: Metric) -> Prediction {
750        Prediction {
751            metric,
752            rationale: "because".into(),
753        }
754    }
755
756    /// Episodes named so the split is known: with `holdout_in = 3` the ids
757    /// below land where the assertions expect. Built by asking `is_holdout`
758    /// rather than by assuming, so the fixture cannot drift from the hash.
759    fn corpus(n: usize, holdout_in: u64, f: impl Fn(usize) -> (RunStats, RunStats)) -> Vec<Pair> {
760        let mut pairs = Vec::new();
761        let mut i = 0;
762        let (mut sel, mut hold) = (0, 0);
763        while sel < n || hold < n.div_ceil(2) {
764            let episode = format!("ep-{i}");
765            i += 1;
766            let is_h = is_holdout(&episode, holdout_in);
767            if is_h && hold >= n.div_ceil(2) {
768                continue;
769            }
770            if !is_h && sel >= n {
771                continue;
772            }
773            if is_h {
774                hold += 1
775            } else {
776                sel += 1
777            }
778            let (baseline, candidate) = f(pairs.len());
779            pairs.push(Pair {
780                episode,
781                baseline,
782                candidate,
783            });
784        }
785        pairs
786    }
787
788    #[test]
789    fn a_change_that_wins_on_both_slices_is_accepted_without_a_person() {
790        let pairs = corpus(12, 3, |_| (run(10, 4, true), run(10, 1, false)));
791        let j = judge(
792            ChangeClass::Config,
793            &prediction(Metric::EndedOnFailedCall),
794            &pairs,
795            3,
796        );
797        assert_eq!(j.disposition, Disposition::Accept, "{j:#?}");
798        assert!(j.selection.wins >= MIN_SELECTION_PAIRS);
799        assert_eq!(j.selection.losses, 0);
800    }
801
802    #[test]
803    fn a_gain_bought_by_attempting_less_is_rejected_however_it_scored() {
804        // The Goodhart case, and the one this gate exists for: every episode
805        // improves on the metric, and the improvement is that the run stopped
806        // doing anything. Measured elsewhere at 30.4% of RE-Bench runs.
807        let pairs = corpus(12, 3, |_| (run(20, 6, true), run(1, 0, false)));
808        let j = judge(
809            ChangeClass::Config,
810            &prediction(Metric::EndedOnFailedCall),
811            &pairs,
812            3,
813        );
814        match j.disposition {
815            Disposition::Reject(ref why) => assert!(why.contains("attempting less"), "{why}"),
816            other => panic!("a suppressed-work win was not rejected: {other:?}"),
817        }
818        assert!(j.work_candidate < j.work_baseline);
819    }
820
821    #[test]
822    fn a_holdout_that_could_not_have_confirmed_anything_does_not_confirm() {
823        // The hole this closes: `not_worse` is `wins >= losses`, so a holdout
824        // of four all-tie episodes satisfies it with 0 and 0 and reads as
825        // "confirmed on unseen work". The holdout is drawn uniformly on
826        // purpose, and on 2026-08-31 the live corpus was 69% runs of two turns
827        // or fewer — so a uniform draw of four was usually four episodes that
828        // could not have moved whatever was predicted.
829        //
830        // Every holdout baseline here has `ended_on_failed_call` false, so no
831        // held-out episode had anything to lose.
832        let pairs: Vec<Pair> = corpus(12, 3, |_| (run(10, 5, true), run(10, 5, true)))
833            .into_iter()
834            .map(|mut p| {
835                if is_holdout(&p.episode, 3) {
836                    p.baseline = run(10, 5, false);
837                    p.candidate = run(10, 5, false);
838                } else {
839                    p.baseline = run(10, 5, true);
840                    p.candidate = run(10, 5, false);
841                }
842                p
843            })
844            .collect();
845        let j = judge(
846            ChangeClass::Config,
847            &prediction(Metric::EndedOnFailedCall),
848            &pairs,
849            3,
850        );
851        match j.disposition {
852            Disposition::Propose(ref why) => {
853                assert!(why.contains("without ever being able to confirm"), "{why}")
854            }
855            other => panic!("a vacuous holdout was treated as confirmation: {other:?}"),
856        }
857    }
858
859    #[test]
860    fn a_win_paid_for_on_a_metric_nobody_predicted_is_not_a_win() {
861        // The work guardrail counts tool calls, so it catches a gain bought by
862        // attempting *less*. It never caught a gain bought by failing *more*:
863        // this candidate does exactly the same amount of work and halves the
864        // predicted metric, while every call it makes now fails.
865        //
866        // Live, this is the trade the loop is most likely to be offered —
867        // `turns` is currently the only metric in the corpus with real
868        // headroom, and spending accuracy to end sooner buys it.
869        let pairs: Vec<Pair> = corpus(12, 3, |_| {
870            let mut baseline = run(10, 1, false);
871            baseline.turns = 10;
872            let mut candidate = run(10, 9, false);
873            candidate.turns = 5;
874            (baseline, candidate)
875        });
876        let j = judge(ChangeClass::Config, &prediction(Metric::Turns), &pairs, 3);
877        match j.disposition {
878            Disposition::Reject(ref why) => {
879                assert!(why.contains("ToolErrorRate"), "{why}");
880                assert!(why.contains("nobody was watching"), "{why}");
881            }
882            other => panic!("a bought win was accepted: {other:?}"),
883        }
884    }
885
886    #[test]
887    fn an_unpredicted_metric_that_holds_steady_does_not_block_a_real_win() {
888        // The guard above must be a cliff, not a ratchet — otherwise noise on
889        // any of five other metrics vetoes every candidate and the loop stops
890        // accepting anything at all, which is the failure it was added to
891        // avoid wearing the opposite costume.
892        let pairs: Vec<Pair> = corpus(12, 3, |_| {
893            let mut baseline = run(10, 2, false);
894            baseline.turns = 10;
895            let mut candidate = run(10, 2, false);
896            candidate.turns = 5;
897            (baseline, candidate)
898        });
899        let j = judge(ChangeClass::Config, &prediction(Metric::Turns), &pairs, 3);
900        assert_eq!(j.disposition, Disposition::Accept, "{:?}", j.disposition);
901    }
902
903    #[test]
904    fn winning_selection_and_losing_the_holdout_is_a_rejection() {
905        // Overfitting made visible: the candidate is better on exactly the
906        // episodes it was chosen on, and worse on the ones it was not.
907        let pairs: Vec<Pair> = corpus(12, 3, |_| (run(10, 5, true), run(10, 5, true)))
908            .into_iter()
909            .map(|mut p| {
910                if is_holdout(&p.episode, 3) {
911                    p.candidate = run(10, 5, true);
912                    p.baseline = run(10, 5, false);
913                } else {
914                    p.baseline = run(10, 5, true);
915                    p.candidate = run(10, 5, false);
916                }
917                p
918            })
919            .collect();
920        let j = judge(
921            ChangeClass::Config,
922            &prediction(Metric::EndedOnFailedCall),
923            &pairs,
924            3,
925        );
926        match j.disposition {
927            Disposition::Reject(ref why) => assert!(why.contains("holdout"), "{why}"),
928            other => panic!("an overfit candidate was not rejected: {other:?}"),
929        }
930    }
931
932    #[test]
933    fn thin_evidence_proposes_rather_than_rejecting() {
934        // An absence of evidence is not evidence of harm. Three episodes that
935        // all improved is exactly the shape a person should read.
936        let pairs = corpus(3, 3, |_| (run(10, 4, true), run(10, 1, false)));
937        let j = judge(
938            ChangeClass::Config,
939            &prediction(Metric::EndedOnFailedCall),
940            &pairs,
941            3,
942        );
943        match j.disposition {
944            Disposition::Propose(ref why) => assert!(why.contains("floor"), "{why}"),
945            other => panic!("thin evidence should propose, not {other:?}"),
946        }
947    }
948
949    #[test]
950    fn architecture_and_security_reach_a_person_however_well_they_score() {
951        let pairs = corpus(12, 3, |_| (run(10, 4, true), run(10, 0, false)));
952        for class in [ChangeClass::Architecture, ChangeClass::Security] {
953            let j = judge(class, &prediction(Metric::EndedOnFailedCall), &pairs, 3);
954            match j.disposition {
955                Disposition::Propose(ref why) => {
956                    assert!(why.contains("person's decision"), "{why}")
957                }
958                other => panic!("{class:?} must not auto-accept: {other:?}"),
959            }
960        }
961    }
962
963    #[test]
964    fn a_run_that_made_no_calls_is_neutral_on_the_error_rate() {
965        // No calls is no evidence, so it must not be scored as a perfect
966        // record — otherwise suppressing work wins on the rate metric too,
967        // and the work guardrail would be the only thing standing.
968        let none = run(0, 0, false);
969        assert_eq!(Metric::ToolErrorRate.of(&none), 0.0);
970        let clean = run(10, 0, false);
971        assert_eq!(Metric::ToolErrorRate.of(&clean), 0.0);
972        // Which is why they tie rather than one beating the other.
973        let pairs = corpus(12, 3, |_| (run(10, 0, false), run(0, 0, false)));
974        let j = judge(
975            ChangeClass::Config,
976            &prediction(Metric::ToolErrorRate),
977            &pairs,
978            3,
979        );
980        assert_eq!(
981            j.selection.wins, 0,
982            "doing nothing must not beat doing well"
983        );
984    }
985
986    #[test]
987    fn the_split_is_stable_across_runs_or_the_holdout_means_nothing() {
988        let ids: Vec<String> = (0..200).map(|i| format!("ep-{i}")).collect();
989        let first: Vec<bool> = ids.iter().map(|e| is_holdout(e, 4)).collect();
990        let again: Vec<bool> = ids.iter().map(|e| is_holdout(e, 4)).collect();
991        assert_eq!(first, again);
992        // And it actually splits: a "holdout" that takes everything or
993        // nothing would pass every test above while measuring nothing.
994        let held = first.iter().filter(|h| **h).count();
995        assert!((20..80).contains(&held), "{held} of 200 held out");
996    }
997
998    #[test]
999    fn a_suite_the_baseline_already_passes_cannot_confirm_an_improvement() {
1000        // The eval side of `MIN_INFORMATIVE_HOLDOUT`, which review noted was
1001        // coupled and untested. In `mecha eval --ab-config` the cost is
1002        // `!passed`, so a held-out case the baseline already passed has cost 0
1003        // and no room to win — exactly the shape the run-side check exists
1004        // for, in a different currency.
1005        //
1006        // A change is not confirmed by cases that were already green. It
1007        // stages for a person instead, which is what the disposition is for.
1008        struct Case {
1009            id: String,
1010            was: bool,
1011            now: bool,
1012        }
1013        let cases: Vec<Case> = (0..24)
1014            .map(|i| Case {
1015                id: format!("case-{i}"),
1016                // Held-out cases all passed under the baseline; selection
1017                // cases were failing and now pass.
1018                was: is_holdout(&format!("case-{i}"), 3),
1019                now: true,
1020            })
1021            .collect();
1022        fn cost(c: &Case) -> (&str, f64, f64) {
1023            (
1024                c.id.as_str(),
1025                f64::from(u8::from(!c.was)),
1026                f64::from(u8::from(!c.now)),
1027            )
1028        }
1029        let refs: Vec<&Case> = cases.iter().collect();
1030        let (hold, sel): (Vec<&Case>, Vec<&Case>) =
1031            refs.into_iter().partition(|c| is_holdout(&c.id, 3));
1032        let j = judge_slices(ChangeClass::Config, &sel, &hold, cost, |_| (6, 6));
1033        match j.disposition {
1034            Disposition::Propose(ref why) => {
1035                assert!(why.contains("without ever being able to confirm"), "{why}")
1036            }
1037            other => panic!("an all-green holdout was read as confirmation: {other:?}"),
1038        }
1039    }
1040
1041    #[test]
1042    fn a_real_regression_is_not_hidden_behind_a_milder_one_earlier_in_the_list() {
1043        // The short-circuit: `guard_regressions` returned on the first metric
1044        // it found something on, so a `0 → nonzero` Propose at `Compactions`
1045        // (index 3) suppressed a ratio breach at `MalformedArgs` (index 5) and
1046        // the reviewer saw only the benign explanation. Each of the other
1047        // tests moves exactly one unpredicted metric, so none of them could
1048        // see it.
1049        let pairs: Vec<Pair> = corpus(12, 3, |_| {
1050            let mut baseline = run(10, 1, false);
1051            baseline.turns = 10;
1052            baseline.compactions = 0;
1053            baseline.malformed_tool_args = 4;
1054            let mut candidate = run(10, 1, false);
1055            candidate.turns = 5;
1056            // Milder, and earlier in `Metric::ALL`.
1057            candidate.compactions = 2;
1058            // Worse, and later: 10 against a 1.25 ceiling on 4.
1059            candidate.malformed_tool_args = 10;
1060            (baseline, candidate)
1061        });
1062        let j = judge(ChangeClass::Config, &prediction(Metric::Turns), &pairs, 3);
1063        match j.disposition {
1064            Disposition::Reject(ref why) => assert!(why.contains("MalformedArgs"), "{why}"),
1065            other => panic!("the worse finding was hidden behind the milder one: {other:?}"),
1066        }
1067    }
1068
1069    #[test]
1070    fn a_cost_appearing_from_nothing_reaches_a_person_rather_than_being_refused() {
1071        // `compactions` is zero across the live corpus, so a
1072        // `compact_at_tokens` low enough to actually compact takes the metric
1073        // from 0 to nonzero. A first version of `guard_regressions` rejected
1074        // that outright, which made one of the four overridable knobs unusable
1075        // for the metric it exists to move — found in review.
1076        //
1077        // Nothing here can tell "compaction started, which is the point" from
1078        // "malformed arguments appeared, which is not". A person can.
1079        let pairs: Vec<Pair> = corpus(12, 3, |_| {
1080            let mut baseline = run(10, 1, false);
1081            baseline.turns = 10;
1082            baseline.compactions = 0;
1083            let mut candidate = run(10, 1, false);
1084            candidate.turns = 5;
1085            candidate.compactions = 2;
1086            (baseline, candidate)
1087        });
1088        let j = judge(ChangeClass::Config, &prediction(Metric::Turns), &pairs, 3);
1089        match j.disposition {
1090            Disposition::Propose(ref why) => {
1091                assert!(why.contains("rose from"), "{why}");
1092                assert!(why.contains("only a person can tell which"), "{why}");
1093            }
1094            other => panic!("the knob's own effect was treated as a regression: {other:?}"),
1095        }
1096    }
1097
1098    #[test]
1099    fn the_generic_gate_grades_case_outcomes_by_the_same_rules() {
1100        // The content-sensitive arm: eval cases scored on whether they passed,
1101        // which is what a prose change needs, since replay holds tool results
1102        // fixed and cannot see a change in what the model said. Same gate, so
1103        // the guardrails and the holdout cannot drift between currencies.
1104        struct Case {
1105            id: String,
1106            was: bool,
1107            now: bool,
1108            calls: u64,
1109        }
1110        let cases: Vec<Case> = (0..24)
1111            .map(|i| Case {
1112                id: format!("case-{i}"),
1113                was: false,
1114                now: true,
1115                calls: 6,
1116            })
1117            .collect();
1118
1119        // A failure is the cost, so passing is a win. A `fn` rather than a
1120        // closure: the gate's `cost` is higher-ranked over the borrow, and an
1121        // un-annotated closure infers a single lifetime that will not unify.
1122        fn cost(c: &Case) -> (&str, f64, f64) {
1123            (
1124                c.id.as_str(),
1125                f64::from(u8::from(!c.was)),
1126                f64::from(u8::from(!c.now)),
1127            )
1128        }
1129        let j = judge_with(ChangeClass::Prose, &cases, cost, |c| (c.calls, c.calls), 3);
1130        assert_eq!(j.disposition, Disposition::Accept, "{j:#?}");
1131
1132        // And the work guardrail applies in this currency too: a prose change
1133        // that passes more cases by attempting less is still buying its win.
1134        let lazy: Vec<Case> = cases
1135            .into_iter()
1136            .map(|mut c| {
1137                c.calls = 6;
1138                c
1139            })
1140            .collect();
1141        let j = judge_with(ChangeClass::Prose, &lazy, cost, |c| (c.calls, 1), 3);
1142        match j.disposition {
1143            Disposition::Reject(ref why) => assert!(why.contains("attempting less"), "{why}"),
1144            other => panic!("the work guardrail did not cross currencies: {other:?}"),
1145        }
1146    }
1147
1148    #[test]
1149    fn an_episode_that_ran_in_only_one_arm_is_dropped_not_scored() {
1150        // A candidate that dies on the hard episodes must not look good on
1151        // the ones it survived.
1152        let mut baseline = BTreeMap::new();
1153        baseline.insert("a".to_string(), run(5, 0, false));
1154        baseline.insert("hard".to_string(), run(5, 3, true));
1155        let mut candidate = BTreeMap::new();
1156        candidate.insert("a".to_string(), run(5, 0, false));
1157
1158        let pairs = pair_arms(&baseline, &candidate);
1159        assert_eq!(pairs.len(), 1);
1160        assert_eq!(pairs[0].episode, "a");
1161    }
1162}
1163
1164#[cfg(test)]
1165mod prioritised_tests {
1166    use super::*;
1167
1168    fn stats(tool_calls: u32, tool_errors: u32) -> RunStats {
1169        RunStats {
1170            tool_calls,
1171            tool_errors,
1172            ..RunStats::default()
1173        }
1174    }
1175
1176    /// Headroom is the metric's own value, and an episode at the floor is the
1177    /// one worth *not* spending a replay on: whatever the change does, it can
1178    /// only tie or worsen.
1179    #[test]
1180    fn an_episode_with_no_room_to_improve_has_no_priority() {
1181        let m = Metric::ToolErrorRate;
1182        assert_eq!(m.headroom(&stats(10, 5)), 0.5);
1183        assert_eq!(m.headroom(&stats(10, 0)), 0.0, "clean run, nothing to fix");
1184        assert_eq!(
1185            m.headroom(&stats(0, 0)),
1186            0.0,
1187            "no calls is no evidence, which the metric already says"
1188        );
1189        assert!(m.headroom(&stats(10, 9)) > m.headroom(&stats(10, 1)));
1190    }
1191
1192    /// **The reason the slices are drawn separately.** `is_holdout` partitions
1193    /// one pool, so if that pool was gathered by headroom, *both* halves carry
1194    /// only high-headroom episodes and the holdout stops being a check on the
1195    /// selection's bias. Drawing it uniformly from the whole corpus is what
1196    /// keeps "confirmed on unseen work" meaning what it says.
1197    #[test]
1198    fn hashing_a_prioritised_pool_yields_a_prioritised_holdout() {
1199        let corpus: Vec<(String, RunStats)> = (0..40)
1200            .map(|i| {
1201                // Half the corpus is clean and can say nothing about the
1202                // error rate; half has real headroom.
1203                let s = if i % 2 == 0 {
1204                    stats(10, 0)
1205                } else {
1206                    stats(10, 4)
1207                };
1208                (format!("ep-{i:02}"), s)
1209            })
1210            .collect();
1211        let m = Metric::ToolErrorRate;
1212
1213        // Gather by priority, then hash-split it the old way.
1214        let mut by_priority = corpus.clone();
1215        by_priority.sort_by(|a, b| m.headroom(&b.1).partial_cmp(&m.headroom(&a.1)).unwrap());
1216        let pool: Vec<&(String, RunStats)> = by_priority.iter().take(20).collect();
1217        let hashed_holdout: Vec<_> = pool.iter().filter(|p| is_holdout(&p.0, 2)).collect();
1218        assert!(
1219            !hashed_holdout.is_empty(),
1220            "the split has to produce a holdout for this to be a real comparison"
1221        );
1222        assert!(
1223            hashed_holdout.iter().all(|p| m.headroom(&p.1) > 0.0),
1224            "every episode in it came from the prioritised pool, so it inherits the bias"
1225        );
1226
1227        // Drawn uniformly from the *whole* corpus instead, it is representative.
1228        let drawn = crate::sample::take_uniform(corpus.clone(), 7, 20);
1229        let zero = drawn.iter().filter(|p| m.headroom(&p.1) == 0.0).count();
1230        assert!(
1231            zero > 0,
1232            "a uniform draw contains episodes the priority would have excluded"
1233        );
1234    }
1235
1236    /// The gate still gates: `judge_drawn` scores the slices it is handed and
1237    /// applies the same guardrails in the same order.
1238    #[test]
1239    fn the_drawn_gate_applies_the_same_guardrails() {
1240        let pair = |id: &str, before: u32, after: u32| Pair {
1241            episode: id.into(),
1242            baseline: stats(10, before),
1243            candidate: stats(10, after),
1244        };
1245        let prediction = Prediction {
1246            metric: Metric::ToolErrorRate,
1247            rationale: String::new(),
1248        };
1249        let selection: Vec<Pair> = (0..MIN_SELECTION_PAIRS)
1250            .map(|i| pair(&format!("s{i}"), 5, 2))
1251            .collect();
1252        let holdout: Vec<Pair> = (0..MIN_HOLDOUT_PAIRS)
1253            .map(|i| pair(&format!("h{i}"), 5, 4))
1254            .collect();
1255        let j = judge_drawn(ChangeClass::Config, &prediction, &selection, &holdout);
1256        assert_eq!(j.disposition, Disposition::Accept);
1257        assert_eq!(j.selection.wins, MIN_SELECTION_PAIRS);
1258
1259        // A thin holdout proposes rather than accepting — unchanged behaviour,
1260        // reached through the new entry point.
1261        let j = judge_drawn(ChangeClass::Config, &prediction, &selection, &holdout[..1]);
1262        assert!(matches!(j.disposition, Disposition::Propose(_)));
1263    }
1264}