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    /// The metric's value for one run. Lower is better for every metric here,
61    /// which is a deliberate constraint rather than a coincidence: a mixed
62    /// polarity is the kind of thing that inverts a comparison silently, so
63    /// anything worth predicting gets phrased as a cost.
64    pub fn of(&self, s: &RunStats) -> f64 {
65        match self {
66            Metric::EndedOnFailedCall => f64::from(u8::from(s.ended_on_failed_call)),
67            Metric::ToolErrorRate => {
68                if s.tool_calls == 0 {
69                    // No calls is no evidence, not a clean record. Neutral,
70                    // so an episode that made no calls in either arm cannot
71                    // be counted as a win by a change that suppressed work.
72                    0.0
73                } else {
74                    f64::from(s.tool_errors) / f64::from(s.tool_calls)
75                }
76            }
77            // The harness ending the run, not a person cancelling it — the
78            // same predicate `doctor` reads. Counting `Interrupted` here made
79            // a cancelled arm a loss on the metric it was predicting.
80            Metric::CutShort => f64::from(u8::from(s.stop_cause.is_some_and(|c| c.cut_short()))),
81            Metric::Compactions => f64::from(s.compactions),
82            Metric::Turns => f64::from(s.turns),
83            Metric::MalformedArgs => f64::from(s.malformed_tool_args),
84        }
85    }
86}
87
88/// The claim a candidate is judged against, made before the measurement.
89#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
90pub struct Prediction {
91    pub metric: Metric,
92    /// Free text from the diagnostician: what it thinks is wrong and why this
93    /// change addresses it. Recorded for the human who reads the proposal —
94    /// never parsed, and never consulted by the decision.
95    pub rationale: String,
96}
97
98/// What kind of change this is, which decides how far it can get without a
99/// person. See §13.2–13.3 of the research.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum ChangeClass {
103    /// A reversible configuration value.
104    Config,
105    /// Text entering the system prompt.
106    Prose,
107    /// A new hook, subagent, trigger, eval case, tool surface, or source
108    /// change. Always a human's call.
109    Architecture,
110    /// The interlock, the path jail, sandbox configuration, outbox routing.
111    /// Human-gated, and the standing recommendation is that these are never
112    /// proposed at all: a loop that can argue for widening its own
113    /// confinement will eventually argue well, and the metric agrees with it
114    /// — a run that can reach the network fails fewer calls.
115    Security,
116}
117
118impl ChangeClass {
119    /// Whether measurement alone can accept this class.
120    fn auto_acceptable(&self) -> bool {
121        matches!(self, ChangeClass::Config | ChangeClass::Prose)
122    }
123}
124
125/// One episode measured in both arms. Paired by `episode`, which is a replay
126/// corpus id — a session id, or an eval case id.
127#[derive(Debug, Clone)]
128pub struct Pair {
129    pub episode: String,
130    pub baseline: RunStats,
131    pub candidate: RunStats,
132}
133
134/// How one slice of the corpus came out.
135#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
136pub struct Tally {
137    pub wins: usize,
138    pub losses: usize,
139    pub ties: usize,
140}
141
142impl Tally {
143    pub fn total(&self) -> usize {
144        self.wins + self.losses + self.ties
145    }
146    fn better(&self) -> bool {
147        self.wins > self.losses
148    }
149    fn not_worse(&self) -> bool {
150        self.wins >= self.losses
151    }
152}
153
154/// What the gate decided, and why in words a human can check.
155#[derive(Debug, Clone, PartialEq, serde::Serialize)]
156pub enum Disposition {
157    /// Measurement carried it: nothing further needed.
158    Accept,
159    /// Measured well but the class requires a person, or the evidence is thin.
160    Propose(String),
161    /// Measured badly, or a guardrail moved.
162    Reject(String),
163}
164
165/// The full result of grading a candidate, kept whole so a proposal records
166/// what it was decided from rather than only the verdict.
167#[derive(Debug, Clone, serde::Serialize)]
168pub struct Judgement {
169    pub disposition: Disposition,
170    pub selection: Tally,
171    pub holdout: Tally,
172    /// Tool calls attempted across each arm — the work guardrail. A change
173    /// that improves its metric by attempting less has not improved anything.
174    pub work_baseline: u64,
175    pub work_candidate: u64,
176}
177
178/// Below this many paired episodes in a slice, a difference is not evidence.
179///
180/// Eight and four, which are small — the constraint is that a replay corpus
181/// costs a real model run per episode per arm, so a floor set where the
182/// statistics would like it is a floor that stops the loop running at all.
183/// The holdout is doing the work that a larger sample would; these numbers
184/// only stop a two-episode coincidence being called a result.
185pub const MIN_SELECTION_PAIRS: usize = 8;
186pub const MIN_HOLDOUT_PAIRS: usize = 4;
187
188/// How far work may fall before a gain is treated as bought rather than
189/// earned. Some drop is legitimate — a change that stops a redundant re-read
190/// does less work and is better for it — so this is a cliff, not a ratchet.
191pub const WORK_FLOOR: f64 = 0.75;
192
193/// Split an episode into selection or holdout, deterministically.
194///
195/// By id hash rather than at random: the same corpus must split the same way
196/// every time or a rerun silently grades a candidate against a different
197/// holdout, and "confirmed on unseen episodes" stops meaning anything. Pure,
198/// so the split is unit-testable.
199pub fn is_holdout(episode: &str, holdout_in: u64) -> bool {
200    // FNV-1a, written out rather than `DefaultHasher`. std explicitly does not
201    // guarantee `DefaultHasher`'s algorithm across releases, so a toolchain
202    // upgrade would re-partition selection and holdout with nothing visible
203    // changing — and "confirmed on episodes it was never chosen on" would
204    // quietly stop being true. The invariant this function exists for is
205    // stability, so the hash has to be one this file owns.
206    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
207    const PRIME: u64 = 0x100_0000_01b3;
208    let mut h = OFFSET;
209    for byte in episode.as_bytes() {
210        h ^= u64::from(*byte);
211        h = h.wrapping_mul(PRIME);
212    }
213    h.is_multiple_of(holdout_in)
214}
215
216/// Grade a candidate against its own prediction.
217pub fn judge(
218    class: ChangeClass,
219    prediction: &Prediction,
220    pairs: &[Pair],
221    holdout_in: u64,
222) -> Judgement {
223    let metric = prediction.metric;
224    judge_with(
225        class,
226        pairs,
227        |p| {
228            (
229                p.episode.as_str(),
230                metric.of(&p.baseline),
231                metric.of(&p.candidate),
232            )
233        },
234        |p| {
235            (
236                u64::from(p.baseline.tool_calls),
237                u64::from(p.candidate.tool_calls),
238            )
239        },
240        holdout_in,
241    )
242}
243
244/// The same gate over anything that can name an episode and produce a cost.
245///
246/// Two currencies grade a candidate here and they are not interchangeable.
247/// Replayed sessions are scored on [`RunStats`] — did the *harness* go better
248/// — while eval cases are scored on whether the case **passed**, which is the
249/// content-sensitive arm a prose change needs, because replay holds tool
250/// results fixed and cannot see a change in what the model actually said. One
251/// gate, so the guardrails and the holdout cannot drift apart between them.
252///
253/// `cost` returns `(episode, baseline, candidate)` and lower must be better,
254/// as in [`Metric`]. `work` returns the two arms' work volume for the Goodhart
255/// guardrail.
256pub fn judge_with<T>(
257    class: ChangeClass,
258    pairs: &[T],
259    cost: impl for<'a> Fn(&'a T) -> (&'a str, f64, f64),
260    work: impl Fn(&T) -> (u64, u64),
261    holdout_in: u64,
262) -> Judgement {
263    let (holdout, selection): (Vec<&T>, Vec<&T>) = pairs
264        .iter()
265        .partition(|p| is_holdout(cost(p).0, holdout_in));
266
267    let tally = |slice: &[&T]| {
268        let mut t = Tally::default();
269        for p in slice {
270            let (_, before, after) = cost(p);
271            // Every metric is a cost, so down is a win.
272            match after.partial_cmp(&before) {
273                Some(std::cmp::Ordering::Less) => t.wins += 1,
274                Some(std::cmp::Ordering::Greater) => t.losses += 1,
275                _ => t.ties += 1,
276            }
277        }
278        t
279    };
280    let sel = tally(&selection);
281    let hold = tally(&holdout);
282
283    let sum = |slice: &[&T], pick: fn((u64, u64)) -> u64| -> u64 {
284        slice.iter().map(|p| pick(work(p))).sum()
285    };
286    let work_baseline = sum(&selection, |(b, _)| b) + sum(&holdout, |(b, _)| b);
287    let work_candidate = sum(&selection, |(_, c)| c) + sum(&holdout, |(_, c)| c);
288
289    let judgement = |disposition| Judgement {
290        disposition,
291        selection: sel.clone(),
292        holdout: hold.clone(),
293        work_baseline,
294        work_candidate,
295    };
296
297    // Order matters: a guardrail breach is a rejection whatever the score, and
298    // thin evidence is not a rejection — it is an absence of one.
299    if work_baseline > 0 && (work_candidate as f64) < work_baseline as f64 * WORK_FLOOR {
300        return judgement(Disposition::Reject(format!(
301            "work fell from {work_baseline} tool calls to {work_candidate}: a gain bought by \
302             attempting less is not a gain"
303        )));
304    }
305    if sel.total() < MIN_SELECTION_PAIRS {
306        return judgement(Disposition::Propose(format!(
307            "only {} paired episode(s) in the selection slice, below the floor of \
308             {MIN_SELECTION_PAIRS} — read it rather than trusting it",
309            sel.total()
310        )));
311    }
312    if !sel.better() {
313        return judgement(Disposition::Reject(format!(
314            "did not beat the original: {} better, {} worse, {} unchanged",
315            sel.wins, sel.losses, sel.ties
316        )));
317    }
318    if hold.total() < MIN_HOLDOUT_PAIRS {
319        return judgement(Disposition::Propose(format!(
320            "won on the selection slice but the holdout has only {} episode(s), below \
321             {MIN_HOLDOUT_PAIRS} — nothing has confirmed it on unseen work",
322            hold.total()
323        )));
324    }
325    if !hold.not_worse() {
326        return judgement(Disposition::Reject(format!(
327            "won on selection and lost on the holdout ({} better, {} worse): the gain did not \
328             survive episodes it was not chosen on",
329            hold.wins, hold.losses
330        )));
331    }
332    if !class.auto_acceptable() {
333        return judgement(Disposition::Propose(format!(
334            "measured better, but a {class:?} change is a person's decision however it scored"
335        )));
336    }
337    judgement(Disposition::Accept)
338}
339
340/// Pair two arms by episode id, dropping anything that ran in only one.
341///
342/// An episode missing from an arm is not a tie and not a loss — it is missing,
343/// and scoring it either way would let a candidate that *crashes* on hard
344/// episodes look good on the ones it survived.
345pub fn pair_arms(
346    baseline: &BTreeMap<String, RunStats>,
347    candidate: &BTreeMap<String, RunStats>,
348) -> Vec<Pair> {
349    baseline
350        .iter()
351        .filter_map(|(episode, b)| {
352            candidate.get(episode).map(|c| Pair {
353                episode: episode.clone(),
354                baseline: b.clone(),
355                candidate: c.clone(),
356            })
357        })
358        .collect()
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::agent::StopCause;
365
366    fn run(calls: u32, errors: u32, ended_failed: bool) -> RunStats {
367        RunStats {
368            tool_calls: calls,
369            tool_errors: errors,
370            ended_on_failed_call: ended_failed,
371            stop_cause: Some(StopCause::Completed),
372            ..RunStats::default()
373        }
374    }
375
376    fn prediction(metric: Metric) -> Prediction {
377        Prediction {
378            metric,
379            rationale: "because".into(),
380        }
381    }
382
383    /// Episodes named so the split is known: with `holdout_in = 3` the ids
384    /// below land where the assertions expect. Built by asking `is_holdout`
385    /// rather than by assuming, so the fixture cannot drift from the hash.
386    fn corpus(n: usize, holdout_in: u64, f: impl Fn(usize) -> (RunStats, RunStats)) -> Vec<Pair> {
387        let mut pairs = Vec::new();
388        let mut i = 0;
389        let (mut sel, mut hold) = (0, 0);
390        while sel < n || hold < n.div_ceil(2) {
391            let episode = format!("ep-{i}");
392            i += 1;
393            let is_h = is_holdout(&episode, holdout_in);
394            if is_h && hold >= n.div_ceil(2) {
395                continue;
396            }
397            if !is_h && sel >= n {
398                continue;
399            }
400            if is_h {
401                hold += 1
402            } else {
403                sel += 1
404            }
405            let (baseline, candidate) = f(pairs.len());
406            pairs.push(Pair {
407                episode,
408                baseline,
409                candidate,
410            });
411        }
412        pairs
413    }
414
415    #[test]
416    fn a_change_that_wins_on_both_slices_is_accepted_without_a_person() {
417        let pairs = corpus(12, 3, |_| (run(10, 4, true), run(10, 1, false)));
418        let j = judge(
419            ChangeClass::Config,
420            &prediction(Metric::EndedOnFailedCall),
421            &pairs,
422            3,
423        );
424        assert_eq!(j.disposition, Disposition::Accept, "{j:#?}");
425        assert!(j.selection.wins >= MIN_SELECTION_PAIRS);
426        assert_eq!(j.selection.losses, 0);
427    }
428
429    #[test]
430    fn a_gain_bought_by_attempting_less_is_rejected_however_it_scored() {
431        // The Goodhart case, and the one this gate exists for: every episode
432        // improves on the metric, and the improvement is that the run stopped
433        // doing anything. Measured elsewhere at 30.4% of RE-Bench runs.
434        let pairs = corpus(12, 3, |_| (run(20, 6, true), run(1, 0, false)));
435        let j = judge(
436            ChangeClass::Config,
437            &prediction(Metric::EndedOnFailedCall),
438            &pairs,
439            3,
440        );
441        match j.disposition {
442            Disposition::Reject(ref why) => assert!(why.contains("attempting less"), "{why}"),
443            other => panic!("a suppressed-work win was not rejected: {other:?}"),
444        }
445        assert!(j.work_candidate < j.work_baseline);
446    }
447
448    #[test]
449    fn winning_selection_and_losing_the_holdout_is_a_rejection() {
450        // Overfitting made visible: the candidate is better on exactly the
451        // episodes it was chosen on, and worse on the ones it was not.
452        let pairs: Vec<Pair> = corpus(12, 3, |_| (run(10, 5, true), run(10, 5, true)))
453            .into_iter()
454            .map(|mut p| {
455                if is_holdout(&p.episode, 3) {
456                    p.candidate = run(10, 5, true);
457                    p.baseline = run(10, 5, false);
458                } else {
459                    p.baseline = run(10, 5, true);
460                    p.candidate = run(10, 5, false);
461                }
462                p
463            })
464            .collect();
465        let j = judge(
466            ChangeClass::Config,
467            &prediction(Metric::EndedOnFailedCall),
468            &pairs,
469            3,
470        );
471        match j.disposition {
472            Disposition::Reject(ref why) => assert!(why.contains("holdout"), "{why}"),
473            other => panic!("an overfit candidate was not rejected: {other:?}"),
474        }
475    }
476
477    #[test]
478    fn thin_evidence_proposes_rather_than_rejecting() {
479        // An absence of evidence is not evidence of harm. Three episodes that
480        // all improved is exactly the shape a person should read.
481        let pairs = corpus(3, 3, |_| (run(10, 4, true), run(10, 1, false)));
482        let j = judge(
483            ChangeClass::Config,
484            &prediction(Metric::EndedOnFailedCall),
485            &pairs,
486            3,
487        );
488        match j.disposition {
489            Disposition::Propose(ref why) => assert!(why.contains("floor"), "{why}"),
490            other => panic!("thin evidence should propose, not {other:?}"),
491        }
492    }
493
494    #[test]
495    fn architecture_and_security_reach_a_person_however_well_they_score() {
496        let pairs = corpus(12, 3, |_| (run(10, 4, true), run(10, 0, false)));
497        for class in [ChangeClass::Architecture, ChangeClass::Security] {
498            let j = judge(class, &prediction(Metric::EndedOnFailedCall), &pairs, 3);
499            match j.disposition {
500                Disposition::Propose(ref why) => {
501                    assert!(why.contains("person's decision"), "{why}")
502                }
503                other => panic!("{class:?} must not auto-accept: {other:?}"),
504            }
505        }
506    }
507
508    #[test]
509    fn a_run_that_made_no_calls_is_neutral_on_the_error_rate() {
510        // No calls is no evidence, so it must not be scored as a perfect
511        // record — otherwise suppressing work wins on the rate metric too,
512        // and the work guardrail would be the only thing standing.
513        let none = run(0, 0, false);
514        assert_eq!(Metric::ToolErrorRate.of(&none), 0.0);
515        let clean = run(10, 0, false);
516        assert_eq!(Metric::ToolErrorRate.of(&clean), 0.0);
517        // Which is why they tie rather than one beating the other.
518        let pairs = corpus(12, 3, |_| (run(10, 0, false), run(0, 0, false)));
519        let j = judge(
520            ChangeClass::Config,
521            &prediction(Metric::ToolErrorRate),
522            &pairs,
523            3,
524        );
525        assert_eq!(
526            j.selection.wins, 0,
527            "doing nothing must not beat doing well"
528        );
529    }
530
531    #[test]
532    fn the_split_is_stable_across_runs_or_the_holdout_means_nothing() {
533        let ids: Vec<String> = (0..200).map(|i| format!("ep-{i}")).collect();
534        let first: Vec<bool> = ids.iter().map(|e| is_holdout(e, 4)).collect();
535        let again: Vec<bool> = ids.iter().map(|e| is_holdout(e, 4)).collect();
536        assert_eq!(first, again);
537        // And it actually splits: a "holdout" that takes everything or
538        // nothing would pass every test above while measuring nothing.
539        let held = first.iter().filter(|h| **h).count();
540        assert!((20..80).contains(&held), "{held} of 200 held out");
541    }
542
543    #[test]
544    fn the_generic_gate_grades_case_outcomes_by_the_same_rules() {
545        // The content-sensitive arm: eval cases scored on whether they passed,
546        // which is what a prose change needs, since replay holds tool results
547        // fixed and cannot see a change in what the model said. Same gate, so
548        // the guardrails and the holdout cannot drift between currencies.
549        struct Case {
550            id: String,
551            was: bool,
552            now: bool,
553            calls: u64,
554        }
555        let cases: Vec<Case> = (0..24)
556            .map(|i| Case {
557                id: format!("case-{i}"),
558                was: false,
559                now: true,
560                calls: 6,
561            })
562            .collect();
563
564        // A failure is the cost, so passing is a win. A `fn` rather than a
565        // closure: the gate's `cost` is higher-ranked over the borrow, and an
566        // un-annotated closure infers a single lifetime that will not unify.
567        fn cost(c: &Case) -> (&str, f64, f64) {
568            (
569                c.id.as_str(),
570                f64::from(u8::from(!c.was)),
571                f64::from(u8::from(!c.now)),
572            )
573        }
574        let j = judge_with(ChangeClass::Prose, &cases, cost, |c| (c.calls, c.calls), 3);
575        assert_eq!(j.disposition, Disposition::Accept, "{j:#?}");
576
577        // And the work guardrail applies in this currency too: a prose change
578        // that passes more cases by attempting less is still buying its win.
579        let lazy: Vec<Case> = cases
580            .into_iter()
581            .map(|mut c| {
582                c.calls = 6;
583                c
584            })
585            .collect();
586        let j = judge_with(ChangeClass::Prose, &lazy, cost, |c| (c.calls, 1), 3);
587        match j.disposition {
588            Disposition::Reject(ref why) => assert!(why.contains("attempting less"), "{why}"),
589            other => panic!("the work guardrail did not cross currencies: {other:?}"),
590        }
591    }
592
593    #[test]
594    fn an_episode_that_ran_in_only_one_arm_is_dropped_not_scored() {
595        // A candidate that dies on the hard episodes must not look good on
596        // the ones it survived.
597        let mut baseline = BTreeMap::new();
598        baseline.insert("a".to_string(), run(5, 0, false));
599        baseline.insert("hard".to_string(), run(5, 3, true));
600        let mut candidate = BTreeMap::new();
601        candidate.insert("a".to_string(), run(5, 0, false));
602
603        let pairs = pair_arms(&baseline, &candidate);
604        assert_eq!(pairs.len(), 1);
605        assert_eq!(pairs[0].episode, "a");
606    }
607}