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    /// How much this episode can say about the metric, higher being more.
61    ///
62    /// **The priority for a prioritised replay draw, and it needs no new
63    /// concept: it is the metric's own value on the recorded run.** Every
64    /// metric here is a cost, so an episode already at zero has no room to
65    /// improve — whatever the change does, that pair can only tie or worsen,
66    /// and it costs a real model run per arm to learn that. An episode with a
67    /// high recorded cost is the one that can discriminate.
68    ///
69    /// This is prioritised experience replay's shape with the sensor that
70    /// exists today. PER samples by |TD error| because a surprising transition
71    /// carries the most information; here the same argument is made with
72    /// headroom, because the appraisal record that would supply a goal error
73    /// is not built yet. When it is, |goal error| joins this rather than
74    /// replacing it — a run can be uninformative about a metric and still be
75    /// the most instructive thing that happened all week.
76    ///
77    /// **It is only ever a priority, never a score.** Drawing the *selection*
78    /// slice this way is safe precisely because selection only picks; the
79    /// holdout, drawn uniformly, is what confirms. See [`judge_drawn`].
80    pub fn headroom(&self, recorded: &RunStats) -> f64 {
81        self.of(recorded)
82    }
83
84    /// The metric's value for one run. Lower is better for every metric here,
85    /// which is a deliberate constraint rather than a coincidence: a mixed
86    /// polarity is the kind of thing that inverts a comparison silently, so
87    /// anything worth predicting gets phrased as a cost.
88    pub fn of(&self, s: &RunStats) -> f64 {
89        match self {
90            Metric::EndedOnFailedCall => f64::from(u8::from(s.ended_on_failed_call)),
91            Metric::ToolErrorRate => {
92                if s.tool_calls == 0 {
93                    // No calls is no evidence, not a clean record. Neutral,
94                    // so an episode that made no calls in either arm cannot
95                    // be counted as a win by a change that suppressed work.
96                    0.0
97                } else {
98                    f64::from(s.tool_errors) / f64::from(s.tool_calls)
99                }
100            }
101            // The harness ending the run, not a person cancelling it — the
102            // same predicate `doctor` reads. Counting `Interrupted` here made
103            // a cancelled arm a loss on the metric it was predicting.
104            Metric::CutShort => f64::from(u8::from(s.stop_cause.is_some_and(|c| c.cut_short()))),
105            Metric::Compactions => f64::from(s.compactions),
106            Metric::Turns => f64::from(s.turns),
107            Metric::MalformedArgs => f64::from(s.malformed_tool_args),
108        }
109    }
110}
111
112/// The claim a candidate is judged against, made before the measurement.
113#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
114pub struct Prediction {
115    pub metric: Metric,
116    /// Free text from the diagnostician: what it thinks is wrong and why this
117    /// change addresses it. Recorded for the human who reads the proposal —
118    /// never parsed, and never consulted by the decision.
119    pub rationale: String,
120}
121
122/// What kind of change this is, which decides how far it can get without a
123/// person. See §13.2–13.3 of the research.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum ChangeClass {
127    /// A reversible configuration value.
128    Config,
129    /// Text entering the system prompt.
130    Prose,
131    /// A new hook, subagent, trigger, eval case, tool surface, or source
132    /// change. Always a human's call.
133    Architecture,
134    /// The interlock, the path jail, sandbox configuration, outbox routing.
135    /// Human-gated, and the standing recommendation is that these are never
136    /// proposed at all: a loop that can argue for widening its own
137    /// confinement will eventually argue well, and the metric agrees with it
138    /// — a run that can reach the network fails fewer calls.
139    Security,
140}
141
142impl ChangeClass {
143    /// Whether measurement alone can accept this class.
144    fn auto_acceptable(&self) -> bool {
145        matches!(self, ChangeClass::Config | ChangeClass::Prose)
146    }
147}
148
149/// One episode measured in both arms. Paired by `episode`, which is a replay
150/// corpus id — a session id, or an eval case id.
151#[derive(Debug, Clone)]
152pub struct Pair {
153    pub episode: String,
154    pub baseline: RunStats,
155    pub candidate: RunStats,
156}
157
158/// How one slice of the corpus came out.
159#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
160pub struct Tally {
161    pub wins: usize,
162    pub losses: usize,
163    pub ties: usize,
164}
165
166impl Tally {
167    pub fn total(&self) -> usize {
168        self.wins + self.losses + self.ties
169    }
170    fn better(&self) -> bool {
171        self.wins > self.losses
172    }
173    fn not_worse(&self) -> bool {
174        self.wins >= self.losses
175    }
176}
177
178/// What the gate decided, and why in words a human can check.
179#[derive(Debug, Clone, PartialEq, serde::Serialize)]
180pub enum Disposition {
181    /// Measurement carried it: nothing further needed.
182    Accept,
183    /// Measured well but the class requires a person, or the evidence is thin.
184    Propose(String),
185    /// Measured badly, or a guardrail moved.
186    Reject(String),
187}
188
189/// The full result of grading a candidate, kept whole so a proposal records
190/// what it was decided from rather than only the verdict.
191#[derive(Debug, Clone, serde::Serialize)]
192pub struct Judgement {
193    pub disposition: Disposition,
194    pub selection: Tally,
195    pub holdout: Tally,
196    /// Tool calls attempted across each arm — the work guardrail. A change
197    /// that improves its metric by attempting less has not improved anything.
198    pub work_baseline: u64,
199    pub work_candidate: u64,
200}
201
202/// Below this many paired episodes in a slice, a difference is not evidence.
203///
204/// Eight and four, which are small — the constraint is that a replay corpus
205/// costs a real model run per episode per arm, so a floor set where the
206/// statistics would like it is a floor that stops the loop running at all.
207/// The holdout is doing the work that a larger sample would; these numbers
208/// only stop a two-episode coincidence being called a result.
209pub const MIN_SELECTION_PAIRS: usize = 8;
210pub const MIN_HOLDOUT_PAIRS: usize = 4;
211
212/// How far work may fall before a gain is treated as bought rather than
213/// earned. Some drop is legitimate — a change that stops a redundant re-read
214/// does less work and is better for it — so this is a cliff, not a ratchet.
215pub const WORK_FLOOR: f64 = 0.75;
216
217/// Split an episode into selection or holdout, deterministically.
218///
219/// By id hash rather than at random: the same corpus must split the same way
220/// every time or a rerun silently grades a candidate against a different
221/// holdout, and "confirmed on unseen episodes" stops meaning anything. Pure,
222/// so the split is unit-testable.
223pub fn is_holdout(episode: &str, holdout_in: u64) -> bool {
224    // FNV-1a, written out rather than `DefaultHasher`. std explicitly does not
225    // guarantee `DefaultHasher`'s algorithm across releases, so a toolchain
226    // upgrade would re-partition selection and holdout with nothing visible
227    // changing — and "confirmed on episodes it was never chosen on" would
228    // quietly stop being true. The invariant this function exists for is
229    // stability, so the hash has to be one this file owns.
230    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
231    const PRIME: u64 = 0x100_0000_01b3;
232    let mut h = OFFSET;
233    for byte in episode.as_bytes() {
234        h ^= u64::from(*byte);
235        h = h.wrapping_mul(PRIME);
236    }
237    h.is_multiple_of(holdout_in)
238}
239
240/// Grade a candidate against its own prediction.
241pub fn judge(
242    class: ChangeClass,
243    prediction: &Prediction,
244    pairs: &[Pair],
245    holdout_in: u64,
246) -> Judgement {
247    let metric = prediction.metric;
248    judge_with(
249        class,
250        pairs,
251        |p| {
252            (
253                p.episode.as_str(),
254                metric.of(&p.baseline),
255                metric.of(&p.candidate),
256            )
257        },
258        |p| {
259            (
260                u64::from(p.baseline.tool_calls),
261                u64::from(p.candidate.tool_calls),
262            )
263        },
264        holdout_in,
265    )
266}
267
268/// Judge two slices the caller drew: selection by priority, holdout uniformly.
269///
270/// The replay path's entry point. `judge` hash-partitions one pool and is
271/// still right for `eval --ab-config`, where every case runs and the pool is
272/// therefore already uniform. A replay corpus is *sampled*, and once it is
273/// sampled by informativeness the partition inherits the bias — see
274/// [`judge_slices`].
275pub fn judge_drawn(
276    class: ChangeClass,
277    prediction: &Prediction,
278    selection: &[Pair],
279    holdout: &[Pair],
280) -> Judgement {
281    let metric = prediction.metric;
282    let sel: Vec<&Pair> = selection.iter().collect();
283    let hold: Vec<&Pair> = holdout.iter().collect();
284    judge_slices(
285        class,
286        &sel,
287        &hold,
288        // Inline rather than bound: a named closure here cannot be inferred
289        // as higher-ranked over the borrow, the same reason `judge` spells
290        // these out at the call.
291        |p| {
292            (
293                p.episode.as_str(),
294                metric.of(&p.baseline),
295                metric.of(&p.candidate),
296            )
297        },
298        |p| {
299            (
300                u64::from(p.baseline.tool_calls),
301                u64::from(p.candidate.tool_calls),
302            )
303        },
304    )
305}
306
307/// The same gate over anything that can name an episode and produce a cost.
308///
309/// Two currencies grade a candidate here and they are not interchangeable.
310/// Replayed sessions are scored on [`RunStats`] — did the *harness* go better
311/// — while eval cases are scored on whether the case **passed**, which is the
312/// content-sensitive arm a prose change needs, because replay holds tool
313/// results fixed and cannot see a change in what the model actually said. One
314/// gate, so the guardrails and the holdout cannot drift apart between them.
315///
316/// `cost` returns `(episode, baseline, candidate)` and lower must be better,
317/// as in [`Metric`]. `work` returns the two arms' work volume for the Goodhart
318/// guardrail.
319pub fn judge_with<T>(
320    class: ChangeClass,
321    pairs: &[T],
322    cost: impl for<'a> Fn(&'a T) -> (&'a str, f64, f64),
323    work: impl Fn(&T) -> (u64, u64),
324    holdout_in: u64,
325) -> Judgement {
326    let (holdout, selection): (Vec<&T>, Vec<&T>) = pairs
327        .iter()
328        .partition(|p| is_holdout(cost(p).0, holdout_in));
329    judge_slices(class, &selection, &holdout, cost, work)
330}
331
332/// The gate over two slices the caller drew itself.
333///
334/// **Extracted because prioritising a corpus prioritises both halves of a
335/// partition of it.** [`is_holdout`] splits one pool, which is right when the
336/// pool was gathered uniformly — every eval case runs, so `--ab-config` still
337/// uses it. It is wrong the moment the pool is drawn by informativeness:
338/// hashing a biased pool yields two biased slices, and the holdout stops being
339/// the thing that corrects the selection's bias. Prioritised experience replay
340/// has the same problem and answers it with importance weights; here the
341/// answer is that the two slices are **drawn separately** — the holdout
342/// uniformly, the selection by [`Metric::headroom`] — and this function's job
343/// is to score whatever it is handed rather than to decide what goes where.
344pub fn judge_slices<T>(
345    class: ChangeClass,
346    selection: &[&T],
347    holdout: &[&T],
348    cost: impl for<'a> Fn(&'a T) -> (&'a str, f64, f64),
349    work: impl Fn(&T) -> (u64, u64),
350) -> Judgement {
351    let (selection, holdout) = (selection.to_vec(), holdout.to_vec());
352    let tally = |slice: &[&T]| {
353        let mut t = Tally::default();
354        for p in slice {
355            let (_, before, after) = cost(p);
356            // Every metric is a cost, so down is a win.
357            match after.partial_cmp(&before) {
358                Some(std::cmp::Ordering::Less) => t.wins += 1,
359                Some(std::cmp::Ordering::Greater) => t.losses += 1,
360                _ => t.ties += 1,
361            }
362        }
363        t
364    };
365    let sel = tally(&selection);
366    let hold = tally(&holdout);
367
368    let sum = |slice: &[&T], pick: fn((u64, u64)) -> u64| -> u64 {
369        slice.iter().map(|p| pick(work(p))).sum()
370    };
371    let work_baseline = sum(&selection, |(b, _)| b) + sum(&holdout, |(b, _)| b);
372    let work_candidate = sum(&selection, |(_, c)| c) + sum(&holdout, |(_, c)| c);
373
374    let judgement = |disposition| Judgement {
375        disposition,
376        selection: sel.clone(),
377        holdout: hold.clone(),
378        work_baseline,
379        work_candidate,
380    };
381
382    // Order matters: a guardrail breach is a rejection whatever the score, and
383    // thin evidence is not a rejection — it is an absence of one.
384    if work_baseline > 0 && (work_candidate as f64) < work_baseline as f64 * WORK_FLOOR {
385        return judgement(Disposition::Reject(format!(
386            "work fell from {work_baseline} tool calls to {work_candidate}: a gain bought by \
387             attempting less is not a gain"
388        )));
389    }
390    if sel.total() < MIN_SELECTION_PAIRS {
391        return judgement(Disposition::Propose(format!(
392            "only {} paired episode(s) in the selection slice, below the floor of \
393             {MIN_SELECTION_PAIRS} — read it rather than trusting it",
394            sel.total()
395        )));
396    }
397    if !sel.better() {
398        return judgement(Disposition::Reject(format!(
399            "did not beat the original: {} better, {} worse, {} unchanged",
400            sel.wins, sel.losses, sel.ties
401        )));
402    }
403    if hold.total() < MIN_HOLDOUT_PAIRS {
404        return judgement(Disposition::Propose(format!(
405            "won on the selection slice but the holdout has only {} episode(s), below \
406             {MIN_HOLDOUT_PAIRS} — nothing has confirmed it on unseen work",
407            hold.total()
408        )));
409    }
410    if !hold.not_worse() {
411        return judgement(Disposition::Reject(format!(
412            "won on selection and lost on the holdout ({} better, {} worse): the gain did not \
413             survive episodes it was not chosen on",
414            hold.wins, hold.losses
415        )));
416    }
417    if !class.auto_acceptable() {
418        return judgement(Disposition::Propose(format!(
419            "measured better, but a {class:?} change is a person's decision however it scored"
420        )));
421    }
422    judgement(Disposition::Accept)
423}
424
425/// Pair two arms by episode id, dropping anything that ran in only one.
426///
427/// An episode missing from an arm is not a tie and not a loss — it is missing,
428/// and scoring it either way would let a candidate that *crashes* on hard
429/// episodes look good on the ones it survived.
430pub fn pair_arms(
431    baseline: &BTreeMap<String, RunStats>,
432    candidate: &BTreeMap<String, RunStats>,
433) -> Vec<Pair> {
434    baseline
435        .iter()
436        .filter_map(|(episode, b)| {
437            candidate.get(episode).map(|c| Pair {
438                episode: episode.clone(),
439                baseline: b.clone(),
440                candidate: c.clone(),
441            })
442        })
443        .collect()
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::agent::StopCause;
450
451    fn run(calls: u32, errors: u32, ended_failed: bool) -> RunStats {
452        RunStats {
453            tool_calls: calls,
454            tool_errors: errors,
455            ended_on_failed_call: ended_failed,
456            stop_cause: Some(StopCause::Completed),
457            ..RunStats::default()
458        }
459    }
460
461    fn prediction(metric: Metric) -> Prediction {
462        Prediction {
463            metric,
464            rationale: "because".into(),
465        }
466    }
467
468    /// Episodes named so the split is known: with `holdout_in = 3` the ids
469    /// below land where the assertions expect. Built by asking `is_holdout`
470    /// rather than by assuming, so the fixture cannot drift from the hash.
471    fn corpus(n: usize, holdout_in: u64, f: impl Fn(usize) -> (RunStats, RunStats)) -> Vec<Pair> {
472        let mut pairs = Vec::new();
473        let mut i = 0;
474        let (mut sel, mut hold) = (0, 0);
475        while sel < n || hold < n.div_ceil(2) {
476            let episode = format!("ep-{i}");
477            i += 1;
478            let is_h = is_holdout(&episode, holdout_in);
479            if is_h && hold >= n.div_ceil(2) {
480                continue;
481            }
482            if !is_h && sel >= n {
483                continue;
484            }
485            if is_h {
486                hold += 1
487            } else {
488                sel += 1
489            }
490            let (baseline, candidate) = f(pairs.len());
491            pairs.push(Pair {
492                episode,
493                baseline,
494                candidate,
495            });
496        }
497        pairs
498    }
499
500    #[test]
501    fn a_change_that_wins_on_both_slices_is_accepted_without_a_person() {
502        let pairs = corpus(12, 3, |_| (run(10, 4, true), run(10, 1, false)));
503        let j = judge(
504            ChangeClass::Config,
505            &prediction(Metric::EndedOnFailedCall),
506            &pairs,
507            3,
508        );
509        assert_eq!(j.disposition, Disposition::Accept, "{j:#?}");
510        assert!(j.selection.wins >= MIN_SELECTION_PAIRS);
511        assert_eq!(j.selection.losses, 0);
512    }
513
514    #[test]
515    fn a_gain_bought_by_attempting_less_is_rejected_however_it_scored() {
516        // The Goodhart case, and the one this gate exists for: every episode
517        // improves on the metric, and the improvement is that the run stopped
518        // doing anything. Measured elsewhere at 30.4% of RE-Bench runs.
519        let pairs = corpus(12, 3, |_| (run(20, 6, true), run(1, 0, false)));
520        let j = judge(
521            ChangeClass::Config,
522            &prediction(Metric::EndedOnFailedCall),
523            &pairs,
524            3,
525        );
526        match j.disposition {
527            Disposition::Reject(ref why) => assert!(why.contains("attempting less"), "{why}"),
528            other => panic!("a suppressed-work win was not rejected: {other:?}"),
529        }
530        assert!(j.work_candidate < j.work_baseline);
531    }
532
533    #[test]
534    fn winning_selection_and_losing_the_holdout_is_a_rejection() {
535        // Overfitting made visible: the candidate is better on exactly the
536        // episodes it was chosen on, and worse on the ones it was not.
537        let pairs: Vec<Pair> = corpus(12, 3, |_| (run(10, 5, true), run(10, 5, true)))
538            .into_iter()
539            .map(|mut p| {
540                if is_holdout(&p.episode, 3) {
541                    p.candidate = run(10, 5, true);
542                    p.baseline = run(10, 5, false);
543                } else {
544                    p.baseline = run(10, 5, true);
545                    p.candidate = run(10, 5, false);
546                }
547                p
548            })
549            .collect();
550        let j = judge(
551            ChangeClass::Config,
552            &prediction(Metric::EndedOnFailedCall),
553            &pairs,
554            3,
555        );
556        match j.disposition {
557            Disposition::Reject(ref why) => assert!(why.contains("holdout"), "{why}"),
558            other => panic!("an overfit candidate was not rejected: {other:?}"),
559        }
560    }
561
562    #[test]
563    fn thin_evidence_proposes_rather_than_rejecting() {
564        // An absence of evidence is not evidence of harm. Three episodes that
565        // all improved is exactly the shape a person should read.
566        let pairs = corpus(3, 3, |_| (run(10, 4, true), run(10, 1, false)));
567        let j = judge(
568            ChangeClass::Config,
569            &prediction(Metric::EndedOnFailedCall),
570            &pairs,
571            3,
572        );
573        match j.disposition {
574            Disposition::Propose(ref why) => assert!(why.contains("floor"), "{why}"),
575            other => panic!("thin evidence should propose, not {other:?}"),
576        }
577    }
578
579    #[test]
580    fn architecture_and_security_reach_a_person_however_well_they_score() {
581        let pairs = corpus(12, 3, |_| (run(10, 4, true), run(10, 0, false)));
582        for class in [ChangeClass::Architecture, ChangeClass::Security] {
583            let j = judge(class, &prediction(Metric::EndedOnFailedCall), &pairs, 3);
584            match j.disposition {
585                Disposition::Propose(ref why) => {
586                    assert!(why.contains("person's decision"), "{why}")
587                }
588                other => panic!("{class:?} must not auto-accept: {other:?}"),
589            }
590        }
591    }
592
593    #[test]
594    fn a_run_that_made_no_calls_is_neutral_on_the_error_rate() {
595        // No calls is no evidence, so it must not be scored as a perfect
596        // record — otherwise suppressing work wins on the rate metric too,
597        // and the work guardrail would be the only thing standing.
598        let none = run(0, 0, false);
599        assert_eq!(Metric::ToolErrorRate.of(&none), 0.0);
600        let clean = run(10, 0, false);
601        assert_eq!(Metric::ToolErrorRate.of(&clean), 0.0);
602        // Which is why they tie rather than one beating the other.
603        let pairs = corpus(12, 3, |_| (run(10, 0, false), run(0, 0, false)));
604        let j = judge(
605            ChangeClass::Config,
606            &prediction(Metric::ToolErrorRate),
607            &pairs,
608            3,
609        );
610        assert_eq!(
611            j.selection.wins, 0,
612            "doing nothing must not beat doing well"
613        );
614    }
615
616    #[test]
617    fn the_split_is_stable_across_runs_or_the_holdout_means_nothing() {
618        let ids: Vec<String> = (0..200).map(|i| format!("ep-{i}")).collect();
619        let first: Vec<bool> = ids.iter().map(|e| is_holdout(e, 4)).collect();
620        let again: Vec<bool> = ids.iter().map(|e| is_holdout(e, 4)).collect();
621        assert_eq!(first, again);
622        // And it actually splits: a "holdout" that takes everything or
623        // nothing would pass every test above while measuring nothing.
624        let held = first.iter().filter(|h| **h).count();
625        assert!((20..80).contains(&held), "{held} of 200 held out");
626    }
627
628    #[test]
629    fn the_generic_gate_grades_case_outcomes_by_the_same_rules() {
630        // The content-sensitive arm: eval cases scored on whether they passed,
631        // which is what a prose change needs, since replay holds tool results
632        // fixed and cannot see a change in what the model said. Same gate, so
633        // the guardrails and the holdout cannot drift between currencies.
634        struct Case {
635            id: String,
636            was: bool,
637            now: bool,
638            calls: u64,
639        }
640        let cases: Vec<Case> = (0..24)
641            .map(|i| Case {
642                id: format!("case-{i}"),
643                was: false,
644                now: true,
645                calls: 6,
646            })
647            .collect();
648
649        // A failure is the cost, so passing is a win. A `fn` rather than a
650        // closure: the gate's `cost` is higher-ranked over the borrow, and an
651        // un-annotated closure infers a single lifetime that will not unify.
652        fn cost(c: &Case) -> (&str, f64, f64) {
653            (
654                c.id.as_str(),
655                f64::from(u8::from(!c.was)),
656                f64::from(u8::from(!c.now)),
657            )
658        }
659        let j = judge_with(ChangeClass::Prose, &cases, cost, |c| (c.calls, c.calls), 3);
660        assert_eq!(j.disposition, Disposition::Accept, "{j:#?}");
661
662        // And the work guardrail applies in this currency too: a prose change
663        // that passes more cases by attempting less is still buying its win.
664        let lazy: Vec<Case> = cases
665            .into_iter()
666            .map(|mut c| {
667                c.calls = 6;
668                c
669            })
670            .collect();
671        let j = judge_with(ChangeClass::Prose, &lazy, cost, |c| (c.calls, 1), 3);
672        match j.disposition {
673            Disposition::Reject(ref why) => assert!(why.contains("attempting less"), "{why}"),
674            other => panic!("the work guardrail did not cross currencies: {other:?}"),
675        }
676    }
677
678    #[test]
679    fn an_episode_that_ran_in_only_one_arm_is_dropped_not_scored() {
680        // A candidate that dies on the hard episodes must not look good on
681        // the ones it survived.
682        let mut baseline = BTreeMap::new();
683        baseline.insert("a".to_string(), run(5, 0, false));
684        baseline.insert("hard".to_string(), run(5, 3, true));
685        let mut candidate = BTreeMap::new();
686        candidate.insert("a".to_string(), run(5, 0, false));
687
688        let pairs = pair_arms(&baseline, &candidate);
689        assert_eq!(pairs.len(), 1);
690        assert_eq!(pairs[0].episode, "a");
691    }
692}
693
694#[cfg(test)]
695mod prioritised_tests {
696    use super::*;
697
698    fn stats(tool_calls: u32, tool_errors: u32) -> RunStats {
699        RunStats {
700            tool_calls,
701            tool_errors,
702            ..RunStats::default()
703        }
704    }
705
706    /// Headroom is the metric's own value, and an episode at the floor is the
707    /// one worth *not* spending a replay on: whatever the change does, it can
708    /// only tie or worsen.
709    #[test]
710    fn an_episode_with_no_room_to_improve_has_no_priority() {
711        let m = Metric::ToolErrorRate;
712        assert_eq!(m.headroom(&stats(10, 5)), 0.5);
713        assert_eq!(m.headroom(&stats(10, 0)), 0.0, "clean run, nothing to fix");
714        assert_eq!(
715            m.headroom(&stats(0, 0)),
716            0.0,
717            "no calls is no evidence, which the metric already says"
718        );
719        assert!(m.headroom(&stats(10, 9)) > m.headroom(&stats(10, 1)));
720    }
721
722    /// **The reason the slices are drawn separately.** `is_holdout` partitions
723    /// one pool, so if that pool was gathered by headroom, *both* halves carry
724    /// only high-headroom episodes and the holdout stops being a check on the
725    /// selection's bias. Drawing it uniformly from the whole corpus is what
726    /// keeps "confirmed on unseen work" meaning what it says.
727    #[test]
728    fn hashing_a_prioritised_pool_yields_a_prioritised_holdout() {
729        let corpus: Vec<(String, RunStats)> = (0..40)
730            .map(|i| {
731                // Half the corpus is clean and can say nothing about the
732                // error rate; half has real headroom.
733                let s = if i % 2 == 0 {
734                    stats(10, 0)
735                } else {
736                    stats(10, 4)
737                };
738                (format!("ep-{i:02}"), s)
739            })
740            .collect();
741        let m = Metric::ToolErrorRate;
742
743        // Gather by priority, then hash-split it the old way.
744        let mut by_priority = corpus.clone();
745        by_priority.sort_by(|a, b| m.headroom(&b.1).partial_cmp(&m.headroom(&a.1)).unwrap());
746        let pool: Vec<&(String, RunStats)> = by_priority.iter().take(20).collect();
747        let hashed_holdout: Vec<_> = pool.iter().filter(|p| is_holdout(&p.0, 2)).collect();
748        assert!(
749            !hashed_holdout.is_empty(),
750            "the split has to produce a holdout for this to be a real comparison"
751        );
752        assert!(
753            hashed_holdout.iter().all(|p| m.headroom(&p.1) > 0.0),
754            "every episode in it came from the prioritised pool, so it inherits the bias"
755        );
756
757        // Drawn uniformly from the *whole* corpus instead, it is representative.
758        let drawn = crate::sample::take_uniform(corpus.clone(), 7, 20);
759        let zero = drawn.iter().filter(|p| m.headroom(&p.1) == 0.0).count();
760        assert!(
761            zero > 0,
762            "a uniform draw contains episodes the priority would have excluded"
763        );
764    }
765
766    /// The gate still gates: `judge_drawn` scores the slices it is handed and
767    /// applies the same guardrails in the same order.
768    #[test]
769    fn the_drawn_gate_applies_the_same_guardrails() {
770        let pair = |id: &str, before: u32, after: u32| Pair {
771            episode: id.into(),
772            baseline: stats(10, before),
773            candidate: stats(10, after),
774        };
775        let prediction = Prediction {
776            metric: Metric::ToolErrorRate,
777            rationale: String::new(),
778        };
779        let selection: Vec<Pair> = (0..MIN_SELECTION_PAIRS)
780            .map(|i| pair(&format!("s{i}"), 5, 2))
781            .collect();
782        let holdout: Vec<Pair> = (0..MIN_HOLDOUT_PAIRS)
783            .map(|i| pair(&format!("h{i}"), 5, 4))
784            .collect();
785        let j = judge_drawn(ChangeClass::Config, &prediction, &selection, &holdout);
786        assert_eq!(j.disposition, Disposition::Accept);
787        assert_eq!(j.selection.wins, MIN_SELECTION_PAIRS);
788
789        // A thin holdout proposes rather than accepting — unchanged behaviour,
790        // reached through the new entry point.
791        let j = judge_drawn(ChangeClass::Config, &prediction, &selection, &holdout[..1]);
792        assert!(matches!(j.disposition, Disposition::Propose(_)));
793    }
794}