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}