mecha-core 0.1.16

Provider-agnostic agent harness: loop, tools, MCP client, sessions.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
//! A proposed harness change, and the decision about it.
//!
//! This is the gate `docs/SELF-IMPROVEMENT-RESEARCH.md` §13.3 specifies, and
//! it is pure on purpose: the arms are run elsewhere, and what arrives here is
//! two sets of [`RunStats`] plus the prediction that was made before either
//! was measured. Getting this wrong is silent — a bad rule that scores well
//! ships and rides in every future prompt — so it is the part that gets unit
//! tests rather than a live trial.
//!
//! ## The shape
//!
//! A candidate carries a **falsifiable prediction** (AHE's decision
//! observability): the metric it claims to move and the direction. Without
//! one, a proposal cannot be refuted by the next measurement, and
//! "harness updating is not harness benefit" is what follows — agents
//! modifying themselves with no corresponding gain.
//!
//! ## Why paired, and why a holdout
//!
//! Episodes differ from each other far more than arms differ from each other,
//! so an unpaired comparison measures which episodes landed in which arm.
//! Pairing by episode removes that. And selecting among candidates on the same
//! episodes that justify the winner is a multiple-comparisons trap: the more
//! candidates, the better the winner looks and the less of it is real. So the
//! corpus is split deterministically, selection happens on one slice, and the
//! winner is confirmed on a slice never used for selection.
//!
//! ## Why counts rather than a significance test
//!
//! Deliberate. With a few dozen episodes the noise is the model's sampling,
//! not the measurement, and the answer to sampling noise is repetition
//! (`--runs k`, pass^k) rather than a p-value over one sample. A test here
//! would put a number on the wrong uncertainty and read as rigour. The raw
//! win/loss/tie counts are reported instead, so a human reading a proposal
//! sees what the decision was made from.

use crate::session::RunStats;
use std::collections::BTreeMap;

/// What a candidate claims it will do.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Metric {
    /// Runs that finished with their last tool call failed.
    EndedOnFailedCall,
    /// Share of attempted tool calls the environment refused.
    ToolErrorRate,
    /// Runs the harness cut short rather than the model finishing.
    CutShort,
    /// Summaries taken. Fewer is better only when the work is unchanged,
    /// which is what the work guardrail is for.
    Compactions,
    /// Turns spent.
    Turns,
    /// Arguments the model produced that did not parse.
    MalformedArgs,
}

impl Metric {
    /// How much this episode can say about the metric, higher being more.
    ///
    /// **The priority for a prioritised replay draw, and it needs no new
    /// concept: it is the metric's own value on the recorded run.** Every
    /// metric here is a cost, so an episode already at zero has no room to
    /// improve — whatever the change does, that pair can only tie or worsen,
    /// and it costs a real model run per arm to learn that. An episode with a
    /// high recorded cost is the one that can discriminate.
    ///
    /// This is prioritised experience replay's shape with the sensor that
    /// exists today. PER samples by |TD error| because a surprising transition
    /// carries the most information; here the same argument is made with
    /// headroom, because the appraisal record that would supply a goal error
    /// is not built yet. When it is, |goal error| joins this rather than
    /// replacing it — a run can be uninformative about a metric and still be
    /// the most instructive thing that happened all week.
    ///
    /// **It is only ever a priority, never a score.** Drawing the *selection*
    /// slice this way is safe precisely because selection only picks; the
    /// holdout, drawn uniformly, is what confirms. See [`judge_drawn`].
    pub fn headroom(&self, recorded: &RunStats) -> f64 {
        self.of(recorded)
    }

    /// The metric's value for one run. Lower is better for every metric here,
    /// which is a deliberate constraint rather than a coincidence: a mixed
    /// polarity is the kind of thing that inverts a comparison silently, so
    /// anything worth predicting gets phrased as a cost.
    pub fn of(&self, s: &RunStats) -> f64 {
        match self {
            Metric::EndedOnFailedCall => f64::from(u8::from(s.ended_on_failed_call)),
            Metric::ToolErrorRate => {
                if s.tool_calls == 0 {
                    // No calls is no evidence, not a clean record. Neutral,
                    // so an episode that made no calls in either arm cannot
                    // be counted as a win by a change that suppressed work.
                    0.0
                } else {
                    f64::from(s.tool_errors) / f64::from(s.tool_calls)
                }
            }
            // The harness ending the run, not a person cancelling it — the
            // same predicate `doctor` reads. Counting `Interrupted` here made
            // a cancelled arm a loss on the metric it was predicting.
            Metric::CutShort => f64::from(u8::from(s.stop_cause.is_some_and(|c| c.cut_short()))),
            Metric::Compactions => f64::from(s.compactions),
            Metric::Turns => f64::from(s.turns),
            Metric::MalformedArgs => f64::from(s.malformed_tool_args),
        }
    }
}

/// The claim a candidate is judged against, made before the measurement.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Prediction {
    pub metric: Metric,
    /// Free text from the diagnostician: what it thinks is wrong and why this
    /// change addresses it. Recorded for the human who reads the proposal —
    /// never parsed, and never consulted by the decision.
    pub rationale: String,
}

/// What kind of change this is, which decides how far it can get without a
/// person. See §13.2–13.3 of the research.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeClass {
    /// A reversible configuration value.
    Config,
    /// Text entering the system prompt.
    Prose,
    /// A new hook, subagent, trigger, eval case, tool surface, or source
    /// change. Always a human's call.
    Architecture,
    /// The interlock, the path jail, sandbox configuration, outbox routing.
    /// Human-gated, and the standing recommendation is that these are never
    /// proposed at all: a loop that can argue for widening its own
    /// confinement will eventually argue well, and the metric agrees with it
    /// — a run that can reach the network fails fewer calls.
    Security,
}

impl ChangeClass {
    /// Whether measurement alone can accept this class.
    fn auto_acceptable(&self) -> bool {
        matches!(self, ChangeClass::Config | ChangeClass::Prose)
    }
}

/// One episode measured in both arms. Paired by `episode`, which is a replay
/// corpus id — a session id, or an eval case id.
#[derive(Debug, Clone)]
pub struct Pair {
    pub episode: String,
    pub baseline: RunStats,
    pub candidate: RunStats,
}

/// How one slice of the corpus came out.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
pub struct Tally {
    pub wins: usize,
    pub losses: usize,
    pub ties: usize,
}

impl Tally {
    pub fn total(&self) -> usize {
        self.wins + self.losses + self.ties
    }
    fn better(&self) -> bool {
        self.wins > self.losses
    }
    fn not_worse(&self) -> bool {
        self.wins >= self.losses
    }
}

/// What the gate decided, and why in words a human can check.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub enum Disposition {
    /// Measurement carried it: nothing further needed.
    Accept,
    /// Measured well but the class requires a person, or the evidence is thin.
    Propose(String),
    /// Measured badly, or a guardrail moved.
    Reject(String),
}

/// The full result of grading a candidate, kept whole so a proposal records
/// what it was decided from rather than only the verdict.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Judgement {
    pub disposition: Disposition,
    pub selection: Tally,
    pub holdout: Tally,
    /// Tool calls attempted across each arm — the work guardrail. A change
    /// that improves its metric by attempting less has not improved anything.
    pub work_baseline: u64,
    pub work_candidate: u64,
}

/// Below this many paired episodes in a slice, a difference is not evidence.
///
/// Eight and four, which are small — the constraint is that a replay corpus
/// costs a real model run per episode per arm, so a floor set where the
/// statistics would like it is a floor that stops the loop running at all.
/// The holdout is doing the work that a larger sample would; these numbers
/// only stop a two-episode coincidence being called a result.
pub const MIN_SELECTION_PAIRS: usize = 8;
pub const MIN_HOLDOUT_PAIRS: usize = 4;

/// How far work may fall before a gain is treated as bought rather than
/// earned. Some drop is legitimate — a change that stops a redundant re-read
/// does less work and is better for it — so this is a cliff, not a ratchet.
pub const WORK_FLOOR: f64 = 0.75;

/// Split an episode into selection or holdout, deterministically.
///
/// By id hash rather than at random: the same corpus must split the same way
/// every time or a rerun silently grades a candidate against a different
/// holdout, and "confirmed on unseen episodes" stops meaning anything. Pure,
/// so the split is unit-testable.
pub fn is_holdout(episode: &str, holdout_in: u64) -> bool {
    // FNV-1a, written out rather than `DefaultHasher`. std explicitly does not
    // guarantee `DefaultHasher`'s algorithm across releases, so a toolchain
    // upgrade would re-partition selection and holdout with nothing visible
    // changing — and "confirmed on episodes it was never chosen on" would
    // quietly stop being true. The invariant this function exists for is
    // stability, so the hash has to be one this file owns.
    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
    const PRIME: u64 = 0x100_0000_01b3;
    let mut h = OFFSET;
    for byte in episode.as_bytes() {
        h ^= u64::from(*byte);
        h = h.wrapping_mul(PRIME);
    }
    h.is_multiple_of(holdout_in)
}

/// Grade a candidate against its own prediction.
pub fn judge(
    class: ChangeClass,
    prediction: &Prediction,
    pairs: &[Pair],
    holdout_in: u64,
) -> Judgement {
    let metric = prediction.metric;
    judge_with(
        class,
        pairs,
        |p| {
            (
                p.episode.as_str(),
                metric.of(&p.baseline),
                metric.of(&p.candidate),
            )
        },
        |p| {
            (
                u64::from(p.baseline.tool_calls),
                u64::from(p.candidate.tool_calls),
            )
        },
        holdout_in,
    )
}

/// Judge two slices the caller drew: selection by priority, holdout uniformly.
///
/// The replay path's entry point. `judge` hash-partitions one pool and is
/// still right for `eval --ab-config`, where every case runs and the pool is
/// therefore already uniform. A replay corpus is *sampled*, and once it is
/// sampled by informativeness the partition inherits the bias — see
/// [`judge_slices`].
pub fn judge_drawn(
    class: ChangeClass,
    prediction: &Prediction,
    selection: &[Pair],
    holdout: &[Pair],
) -> Judgement {
    let metric = prediction.metric;
    let sel: Vec<&Pair> = selection.iter().collect();
    let hold: Vec<&Pair> = holdout.iter().collect();
    judge_slices(
        class,
        &sel,
        &hold,
        // Inline rather than bound: a named closure here cannot be inferred
        // as higher-ranked over the borrow, the same reason `judge` spells
        // these out at the call.
        |p| {
            (
                p.episode.as_str(),
                metric.of(&p.baseline),
                metric.of(&p.candidate),
            )
        },
        |p| {
            (
                u64::from(p.baseline.tool_calls),
                u64::from(p.candidate.tool_calls),
            )
        },
    )
}

/// The same gate over anything that can name an episode and produce a cost.
///
/// Two currencies grade a candidate here and they are not interchangeable.
/// Replayed sessions are scored on [`RunStats`] — did the *harness* go better
/// — while eval cases are scored on whether the case **passed**, which is the
/// content-sensitive arm a prose change needs, because replay holds tool
/// results fixed and cannot see a change in what the model actually said. One
/// gate, so the guardrails and the holdout cannot drift apart between them.
///
/// `cost` returns `(episode, baseline, candidate)` and lower must be better,
/// as in [`Metric`]. `work` returns the two arms' work volume for the Goodhart
/// guardrail.
pub fn judge_with<T>(
    class: ChangeClass,
    pairs: &[T],
    cost: impl for<'a> Fn(&'a T) -> (&'a str, f64, f64),
    work: impl Fn(&T) -> (u64, u64),
    holdout_in: u64,
) -> Judgement {
    let (holdout, selection): (Vec<&T>, Vec<&T>) = pairs
        .iter()
        .partition(|p| is_holdout(cost(p).0, holdout_in));
    judge_slices(class, &selection, &holdout, cost, work)
}

/// The gate over two slices the caller drew itself.
///
/// **Extracted because prioritising a corpus prioritises both halves of a
/// partition of it.** [`is_holdout`] splits one pool, which is right when the
/// pool was gathered uniformly — every eval case runs, so `--ab-config` still
/// uses it. It is wrong the moment the pool is drawn by informativeness:
/// hashing a biased pool yields two biased slices, and the holdout stops being
/// the thing that corrects the selection's bias. Prioritised experience replay
/// has the same problem and answers it with importance weights; here the
/// answer is that the two slices are **drawn separately** — the holdout
/// uniformly, the selection by [`Metric::headroom`] — and this function's job
/// is to score whatever it is handed rather than to decide what goes where.
pub fn judge_slices<T>(
    class: ChangeClass,
    selection: &[&T],
    holdout: &[&T],
    cost: impl for<'a> Fn(&'a T) -> (&'a str, f64, f64),
    work: impl Fn(&T) -> (u64, u64),
) -> Judgement {
    let (selection, holdout) = (selection.to_vec(), holdout.to_vec());
    let tally = |slice: &[&T]| {
        let mut t = Tally::default();
        for p in slice {
            let (_, before, after) = cost(p);
            // Every metric is a cost, so down is a win.
            match after.partial_cmp(&before) {
                Some(std::cmp::Ordering::Less) => t.wins += 1,
                Some(std::cmp::Ordering::Greater) => t.losses += 1,
                _ => t.ties += 1,
            }
        }
        t
    };
    let sel = tally(&selection);
    let hold = tally(&holdout);

    let sum = |slice: &[&T], pick: fn((u64, u64)) -> u64| -> u64 {
        slice.iter().map(|p| pick(work(p))).sum()
    };
    let work_baseline = sum(&selection, |(b, _)| b) + sum(&holdout, |(b, _)| b);
    let work_candidate = sum(&selection, |(_, c)| c) + sum(&holdout, |(_, c)| c);

    let judgement = |disposition| Judgement {
        disposition,
        selection: sel.clone(),
        holdout: hold.clone(),
        work_baseline,
        work_candidate,
    };

    // Order matters: a guardrail breach is a rejection whatever the score, and
    // thin evidence is not a rejection — it is an absence of one.
    if work_baseline > 0 && (work_candidate as f64) < work_baseline as f64 * WORK_FLOOR {
        return judgement(Disposition::Reject(format!(
            "work fell from {work_baseline} tool calls to {work_candidate}: a gain bought by \
             attempting less is not a gain"
        )));
    }
    if sel.total() < MIN_SELECTION_PAIRS {
        return judgement(Disposition::Propose(format!(
            "only {} paired episode(s) in the selection slice, below the floor of \
             {MIN_SELECTION_PAIRS} — read it rather than trusting it",
            sel.total()
        )));
    }
    if !sel.better() {
        return judgement(Disposition::Reject(format!(
            "did not beat the original: {} better, {} worse, {} unchanged",
            sel.wins, sel.losses, sel.ties
        )));
    }
    if hold.total() < MIN_HOLDOUT_PAIRS {
        return judgement(Disposition::Propose(format!(
            "won on the selection slice but the holdout has only {} episode(s), below \
             {MIN_HOLDOUT_PAIRS} — nothing has confirmed it on unseen work",
            hold.total()
        )));
    }
    if !hold.not_worse() {
        return judgement(Disposition::Reject(format!(
            "won on selection and lost on the holdout ({} better, {} worse): the gain did not \
             survive episodes it was not chosen on",
            hold.wins, hold.losses
        )));
    }
    if !class.auto_acceptable() {
        return judgement(Disposition::Propose(format!(
            "measured better, but a {class:?} change is a person's decision however it scored"
        )));
    }
    judgement(Disposition::Accept)
}

/// Pair two arms by episode id, dropping anything that ran in only one.
///
/// An episode missing from an arm is not a tie and not a loss — it is missing,
/// and scoring it either way would let a candidate that *crashes* on hard
/// episodes look good on the ones it survived.
pub fn pair_arms(
    baseline: &BTreeMap<String, RunStats>,
    candidate: &BTreeMap<String, RunStats>,
) -> Vec<Pair> {
    baseline
        .iter()
        .filter_map(|(episode, b)| {
            candidate.get(episode).map(|c| Pair {
                episode: episode.clone(),
                baseline: b.clone(),
                candidate: c.clone(),
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent::StopCause;

    fn run(calls: u32, errors: u32, ended_failed: bool) -> RunStats {
        RunStats {
            tool_calls: calls,
            tool_errors: errors,
            ended_on_failed_call: ended_failed,
            stop_cause: Some(StopCause::Completed),
            ..RunStats::default()
        }
    }

    fn prediction(metric: Metric) -> Prediction {
        Prediction {
            metric,
            rationale: "because".into(),
        }
    }

    /// Episodes named so the split is known: with `holdout_in = 3` the ids
    /// below land where the assertions expect. Built by asking `is_holdout`
    /// rather than by assuming, so the fixture cannot drift from the hash.
    fn corpus(n: usize, holdout_in: u64, f: impl Fn(usize) -> (RunStats, RunStats)) -> Vec<Pair> {
        let mut pairs = Vec::new();
        let mut i = 0;
        let (mut sel, mut hold) = (0, 0);
        while sel < n || hold < n.div_ceil(2) {
            let episode = format!("ep-{i}");
            i += 1;
            let is_h = is_holdout(&episode, holdout_in);
            if is_h && hold >= n.div_ceil(2) {
                continue;
            }
            if !is_h && sel >= n {
                continue;
            }
            if is_h {
                hold += 1
            } else {
                sel += 1
            }
            let (baseline, candidate) = f(pairs.len());
            pairs.push(Pair {
                episode,
                baseline,
                candidate,
            });
        }
        pairs
    }

    #[test]
    fn a_change_that_wins_on_both_slices_is_accepted_without_a_person() {
        let pairs = corpus(12, 3, |_| (run(10, 4, true), run(10, 1, false)));
        let j = judge(
            ChangeClass::Config,
            &prediction(Metric::EndedOnFailedCall),
            &pairs,
            3,
        );
        assert_eq!(j.disposition, Disposition::Accept, "{j:#?}");
        assert!(j.selection.wins >= MIN_SELECTION_PAIRS);
        assert_eq!(j.selection.losses, 0);
    }

    #[test]
    fn a_gain_bought_by_attempting_less_is_rejected_however_it_scored() {
        // The Goodhart case, and the one this gate exists for: every episode
        // improves on the metric, and the improvement is that the run stopped
        // doing anything. Measured elsewhere at 30.4% of RE-Bench runs.
        let pairs = corpus(12, 3, |_| (run(20, 6, true), run(1, 0, false)));
        let j = judge(
            ChangeClass::Config,
            &prediction(Metric::EndedOnFailedCall),
            &pairs,
            3,
        );
        match j.disposition {
            Disposition::Reject(ref why) => assert!(why.contains("attempting less"), "{why}"),
            other => panic!("a suppressed-work win was not rejected: {other:?}"),
        }
        assert!(j.work_candidate < j.work_baseline);
    }

    #[test]
    fn winning_selection_and_losing_the_holdout_is_a_rejection() {
        // Overfitting made visible: the candidate is better on exactly the
        // episodes it was chosen on, and worse on the ones it was not.
        let pairs: Vec<Pair> = corpus(12, 3, |_| (run(10, 5, true), run(10, 5, true)))
            .into_iter()
            .map(|mut p| {
                if is_holdout(&p.episode, 3) {
                    p.candidate = run(10, 5, true);
                    p.baseline = run(10, 5, false);
                } else {
                    p.baseline = run(10, 5, true);
                    p.candidate = run(10, 5, false);
                }
                p
            })
            .collect();
        let j = judge(
            ChangeClass::Config,
            &prediction(Metric::EndedOnFailedCall),
            &pairs,
            3,
        );
        match j.disposition {
            Disposition::Reject(ref why) => assert!(why.contains("holdout"), "{why}"),
            other => panic!("an overfit candidate was not rejected: {other:?}"),
        }
    }

    #[test]
    fn thin_evidence_proposes_rather_than_rejecting() {
        // An absence of evidence is not evidence of harm. Three episodes that
        // all improved is exactly the shape a person should read.
        let pairs = corpus(3, 3, |_| (run(10, 4, true), run(10, 1, false)));
        let j = judge(
            ChangeClass::Config,
            &prediction(Metric::EndedOnFailedCall),
            &pairs,
            3,
        );
        match j.disposition {
            Disposition::Propose(ref why) => assert!(why.contains("floor"), "{why}"),
            other => panic!("thin evidence should propose, not {other:?}"),
        }
    }

    #[test]
    fn architecture_and_security_reach_a_person_however_well_they_score() {
        let pairs = corpus(12, 3, |_| (run(10, 4, true), run(10, 0, false)));
        for class in [ChangeClass::Architecture, ChangeClass::Security] {
            let j = judge(class, &prediction(Metric::EndedOnFailedCall), &pairs, 3);
            match j.disposition {
                Disposition::Propose(ref why) => {
                    assert!(why.contains("person's decision"), "{why}")
                }
                other => panic!("{class:?} must not auto-accept: {other:?}"),
            }
        }
    }

    #[test]
    fn a_run_that_made_no_calls_is_neutral_on_the_error_rate() {
        // No calls is no evidence, so it must not be scored as a perfect
        // record — otherwise suppressing work wins on the rate metric too,
        // and the work guardrail would be the only thing standing.
        let none = run(0, 0, false);
        assert_eq!(Metric::ToolErrorRate.of(&none), 0.0);
        let clean = run(10, 0, false);
        assert_eq!(Metric::ToolErrorRate.of(&clean), 0.0);
        // Which is why they tie rather than one beating the other.
        let pairs = corpus(12, 3, |_| (run(10, 0, false), run(0, 0, false)));
        let j = judge(
            ChangeClass::Config,
            &prediction(Metric::ToolErrorRate),
            &pairs,
            3,
        );
        assert_eq!(
            j.selection.wins, 0,
            "doing nothing must not beat doing well"
        );
    }

    #[test]
    fn the_split_is_stable_across_runs_or_the_holdout_means_nothing() {
        let ids: Vec<String> = (0..200).map(|i| format!("ep-{i}")).collect();
        let first: Vec<bool> = ids.iter().map(|e| is_holdout(e, 4)).collect();
        let again: Vec<bool> = ids.iter().map(|e| is_holdout(e, 4)).collect();
        assert_eq!(first, again);
        // And it actually splits: a "holdout" that takes everything or
        // nothing would pass every test above while measuring nothing.
        let held = first.iter().filter(|h| **h).count();
        assert!((20..80).contains(&held), "{held} of 200 held out");
    }

    #[test]
    fn the_generic_gate_grades_case_outcomes_by_the_same_rules() {
        // The content-sensitive arm: eval cases scored on whether they passed,
        // which is what a prose change needs, since replay holds tool results
        // fixed and cannot see a change in what the model said. Same gate, so
        // the guardrails and the holdout cannot drift between currencies.
        struct Case {
            id: String,
            was: bool,
            now: bool,
            calls: u64,
        }
        let cases: Vec<Case> = (0..24)
            .map(|i| Case {
                id: format!("case-{i}"),
                was: false,
                now: true,
                calls: 6,
            })
            .collect();

        // A failure is the cost, so passing is a win. A `fn` rather than a
        // closure: the gate's `cost` is higher-ranked over the borrow, and an
        // un-annotated closure infers a single lifetime that will not unify.
        fn cost(c: &Case) -> (&str, f64, f64) {
            (
                c.id.as_str(),
                f64::from(u8::from(!c.was)),
                f64::from(u8::from(!c.now)),
            )
        }
        let j = judge_with(ChangeClass::Prose, &cases, cost, |c| (c.calls, c.calls), 3);
        assert_eq!(j.disposition, Disposition::Accept, "{j:#?}");

        // And the work guardrail applies in this currency too: a prose change
        // that passes more cases by attempting less is still buying its win.
        let lazy: Vec<Case> = cases
            .into_iter()
            .map(|mut c| {
                c.calls = 6;
                c
            })
            .collect();
        let j = judge_with(ChangeClass::Prose, &lazy, cost, |c| (c.calls, 1), 3);
        match j.disposition {
            Disposition::Reject(ref why) => assert!(why.contains("attempting less"), "{why}"),
            other => panic!("the work guardrail did not cross currencies: {other:?}"),
        }
    }

    #[test]
    fn an_episode_that_ran_in_only_one_arm_is_dropped_not_scored() {
        // A candidate that dies on the hard episodes must not look good on
        // the ones it survived.
        let mut baseline = BTreeMap::new();
        baseline.insert("a".to_string(), run(5, 0, false));
        baseline.insert("hard".to_string(), run(5, 3, true));
        let mut candidate = BTreeMap::new();
        candidate.insert("a".to_string(), run(5, 0, false));

        let pairs = pair_arms(&baseline, &candidate);
        assert_eq!(pairs.len(), 1);
        assert_eq!(pairs[0].episode, "a");
    }
}

#[cfg(test)]
mod prioritised_tests {
    use super::*;

    fn stats(tool_calls: u32, tool_errors: u32) -> RunStats {
        RunStats {
            tool_calls,
            tool_errors,
            ..RunStats::default()
        }
    }

    /// Headroom is the metric's own value, and an episode at the floor is the
    /// one worth *not* spending a replay on: whatever the change does, it can
    /// only tie or worsen.
    #[test]
    fn an_episode_with_no_room_to_improve_has_no_priority() {
        let m = Metric::ToolErrorRate;
        assert_eq!(m.headroom(&stats(10, 5)), 0.5);
        assert_eq!(m.headroom(&stats(10, 0)), 0.0, "clean run, nothing to fix");
        assert_eq!(
            m.headroom(&stats(0, 0)),
            0.0,
            "no calls is no evidence, which the metric already says"
        );
        assert!(m.headroom(&stats(10, 9)) > m.headroom(&stats(10, 1)));
    }

    /// **The reason the slices are drawn separately.** `is_holdout` partitions
    /// one pool, so if that pool was gathered by headroom, *both* halves carry
    /// only high-headroom episodes and the holdout stops being a check on the
    /// selection's bias. Drawing it uniformly from the whole corpus is what
    /// keeps "confirmed on unseen work" meaning what it says.
    #[test]
    fn hashing_a_prioritised_pool_yields_a_prioritised_holdout() {
        let corpus: Vec<(String, RunStats)> = (0..40)
            .map(|i| {
                // Half the corpus is clean and can say nothing about the
                // error rate; half has real headroom.
                let s = if i % 2 == 0 {
                    stats(10, 0)
                } else {
                    stats(10, 4)
                };
                (format!("ep-{i:02}"), s)
            })
            .collect();
        let m = Metric::ToolErrorRate;

        // Gather by priority, then hash-split it the old way.
        let mut by_priority = corpus.clone();
        by_priority.sort_by(|a, b| m.headroom(&b.1).partial_cmp(&m.headroom(&a.1)).unwrap());
        let pool: Vec<&(String, RunStats)> = by_priority.iter().take(20).collect();
        let hashed_holdout: Vec<_> = pool.iter().filter(|p| is_holdout(&p.0, 2)).collect();
        assert!(
            !hashed_holdout.is_empty(),
            "the split has to produce a holdout for this to be a real comparison"
        );
        assert!(
            hashed_holdout.iter().all(|p| m.headroom(&p.1) > 0.0),
            "every episode in it came from the prioritised pool, so it inherits the bias"
        );

        // Drawn uniformly from the *whole* corpus instead, it is representative.
        let drawn = crate::sample::take_uniform(corpus.clone(), 7, 20);
        let zero = drawn.iter().filter(|p| m.headroom(&p.1) == 0.0).count();
        assert!(
            zero > 0,
            "a uniform draw contains episodes the priority would have excluded"
        );
    }

    /// The gate still gates: `judge_drawn` scores the slices it is handed and
    /// applies the same guardrails in the same order.
    #[test]
    fn the_drawn_gate_applies_the_same_guardrails() {
        let pair = |id: &str, before: u32, after: u32| Pair {
            episode: id.into(),
            baseline: stats(10, before),
            candidate: stats(10, after),
        };
        let prediction = Prediction {
            metric: Metric::ToolErrorRate,
            rationale: String::new(),
        };
        let selection: Vec<Pair> = (0..MIN_SELECTION_PAIRS)
            .map(|i| pair(&format!("s{i}"), 5, 2))
            .collect();
        let holdout: Vec<Pair> = (0..MIN_HOLDOUT_PAIRS)
            .map(|i| pair(&format!("h{i}"), 5, 4))
            .collect();
        let j = judge_drawn(ChangeClass::Config, &prediction, &selection, &holdout);
        assert_eq!(j.disposition, Disposition::Accept);
        assert_eq!(j.selection.wins, MIN_SELECTION_PAIRS);

        // A thin holdout proposes rather than accepting — unchanged behaviour,
        // reached through the new entry point.
        let j = judge_drawn(ChangeClass::Config, &prediction, &selection, &holdout[..1]);
        assert!(matches!(j.disposition, Disposition::Propose(_)));
    }
}