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}