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}