use crate::session::RunStats;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Metric {
EndedOnFailedCall,
ToolErrorRate,
CutShort,
Compactions,
Turns,
MalformedArgs,
}
impl Metric {
pub const ALL: [Metric; 6] = [
Metric::EndedOnFailedCall,
Metric::ToolErrorRate,
Metric::CutShort,
Metric::Compactions,
Metric::Turns,
Metric::MalformedArgs,
];
pub fn as_str(&self) -> &'static str {
match self {
Metric::EndedOnFailedCall => "ended_on_failed_call",
Metric::ToolErrorRate => "tool_error_rate",
Metric::CutShort => "cut_short",
Metric::Compactions => "compactions",
Metric::Turns => "turns",
Metric::MalformedArgs => "malformed_args",
}
}
pub fn headroom(&self, recorded: &RunStats) -> f64 {
self.of(recorded)
}
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 {
0.0
} else {
f64::from(s.tool_errors) / f64::from(s.tool_calls)
}
}
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),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Prediction {
pub metric: Metric,
pub rationale: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeClass {
Config,
Prose,
Architecture,
Security,
}
impl ChangeClass {
fn auto_acceptable(&self) -> bool {
matches!(self, ChangeClass::Config | ChangeClass::Prose)
}
}
#[derive(Debug, Clone)]
pub struct Pair {
pub episode: String,
pub baseline: RunStats,
pub candidate: RunStats,
}
#[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
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub enum Disposition {
Accept,
Propose(String),
Reject(String),
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Judgement {
pub disposition: Disposition,
pub selection: Tally,
pub holdout: Tally,
pub work_baseline: u64,
pub work_candidate: u64,
}
pub const MIN_SELECTION_PAIRS: usize = 8;
pub const MIN_HOLDOUT_PAIRS: usize = 4;
pub const MIN_INFORMATIVE_HOLDOUT: usize = MIN_HOLDOUT_PAIRS;
pub const REGRESSION_CEILING: f64 = 1.25;
pub const MIN_MEASURABLE_RUNS: usize = MIN_SELECTION_PAIRS + MIN_HOLDOUT_PAIRS;
pub fn measurable(runs: usize) -> bool {
runs >= MIN_MEASURABLE_RUNS
}
pub const WORK_FLOOR: f64 = 0.75;
pub fn is_holdout(episode: &str, holdout_in: u64) -> bool {
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)
}
fn guard_regressions<'a>(
j: Judgement,
predicted: Metric,
pairs: impl Iterator<Item = &'a Pair> + Clone,
) -> Judgement {
if j.disposition != Disposition::Accept {
return j;
}
let mut appeared: Option<String> = None;
for metric in Metric::ALL {
if metric == predicted {
continue;
}
let total = |pick: fn(&Pair) -> &RunStats| -> f64 {
pairs.clone().map(|p| metric.of(pick(p))).sum()
};
let (before, after) = (total(|p| &p.baseline), total(|p| &p.candidate));
if before > 0.0 {
if after > before * REGRESSION_CEILING {
return Judgement {
disposition: Disposition::Reject(format!(
"predicted a lower {predicted:?} and got one, but {metric:?} rose from \
{before:.2} to {after:.2} across the same episodes: a win paid for on \
a metric nobody was watching is not a win"
)),
..j
};
}
} else if after > 0.0 && appeared.is_none() {
appeared = Some(format!(
"predicted a lower {predicted:?} and got one, but {metric:?} rose from \
nothing to {after:.2} across the same episodes — a cost that was not there \
before. That is the intended effect for some changes and a regression for \
others, and only a person can tell which"
));
}
}
match appeared {
Some(why) => Judgement {
disposition: Disposition::Propose(why),
..j
},
None => j,
}
}
pub fn judge(
class: ChangeClass,
prediction: &Prediction,
pairs: &[Pair],
holdout_in: u64,
) -> Judgement {
let metric = prediction.metric;
let judged = 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,
);
guard_regressions(judged, metric, pairs.iter())
}
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();
let judged = judge_slices(
class,
&sel,
&hold,
|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),
)
},
);
guard_regressions(judged, metric, selection.iter().chain(holdout.iter()))
}
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)
}
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);
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,
};
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
)));
}
let informative = holdout
.iter()
.filter(|p| {
let (_, before, _) = cost(p);
before > 0.0
})
.count();
if informative < MIN_INFORMATIVE_HOLDOUT {
return judgement(Disposition::Propose(format!(
"won on the selection slice, and nothing got worse on the holdout — but only \
{informative} of {} held-out episode(s) had any of this metric to begin with, \
so the holdout ruled out a regression without ever being able to confirm a gain",
hold.total()
)));
}
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)
}
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::*;
#[test]
fn a_corpus_that_cannot_fill_both_slices_cannot_measure() {
assert!(!measurable(0));
assert!(!measurable(MIN_SELECTION_PAIRS));
assert!(!measurable(MIN_MEASURABLE_RUNS - 1));
assert!(measurable(MIN_MEASURABLE_RUNS));
assert!(!measurable(11));
assert!(measurable(236));
}
#[test]
fn every_metric_variant_reaches_all() {
for m in Metric::ALL {
match m {
Metric::EndedOnFailedCall
| Metric::ToolErrorRate
| Metric::CutShort
| Metric::Compactions
| Metric::Turns
| Metric::MalformedArgs => {}
}
}
assert_eq!(
Metric::ALL.len(),
6,
"a Metric variant was added or removed without updating ALL — the brief, \
guard_regressions and the drift tests all read it"
);
}
#[test]
fn every_metric_name_is_its_serde_spelling() {
for m in Metric::ALL {
let wire = serde_json::to_string(&m).unwrap();
assert_eq!(wire.trim_matches('"'), m.as_str());
}
}
#[test]
fn a_metric_no_run_has_any_of_is_visible_as_zero_headroom() {
let completed = RunStats {
stop_cause: Some(crate::agent::StopCause::Completed),
..Default::default()
};
let interrupted = RunStats {
stop_cause: Some(crate::agent::StopCause::Interrupted),
..Default::default()
};
assert_eq!(Metric::CutShort.of(&completed), 0.0);
assert_eq!(Metric::CutShort.of(&interrupted), 0.0);
let cut = RunStats {
stop_cause: Some(crate::agent::StopCause::MaxTurns),
..Default::default()
};
assert_eq!(Metric::CutShort.of(&cut), 1.0);
}
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(),
}
}
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() {
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 a_holdout_that_could_not_have_confirmed_anything_does_not_confirm() {
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.baseline = run(10, 5, false);
p.candidate = 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::Propose(ref why) => {
assert!(why.contains("without ever being able to confirm"), "{why}")
}
other => panic!("a vacuous holdout was treated as confirmation: {other:?}"),
}
}
#[test]
fn a_win_paid_for_on_a_metric_nobody_predicted_is_not_a_win() {
let pairs: Vec<Pair> = corpus(12, 3, |_| {
let mut baseline = run(10, 1, false);
baseline.turns = 10;
let mut candidate = run(10, 9, false);
candidate.turns = 5;
(baseline, candidate)
});
let j = judge(ChangeClass::Config, &prediction(Metric::Turns), &pairs, 3);
match j.disposition {
Disposition::Reject(ref why) => {
assert!(why.contains("ToolErrorRate"), "{why}");
assert!(why.contains("nobody was watching"), "{why}");
}
other => panic!("a bought win was accepted: {other:?}"),
}
}
#[test]
fn an_unpredicted_metric_that_holds_steady_does_not_block_a_real_win() {
let pairs: Vec<Pair> = corpus(12, 3, |_| {
let mut baseline = run(10, 2, false);
baseline.turns = 10;
let mut candidate = run(10, 2, false);
candidate.turns = 5;
(baseline, candidate)
});
let j = judge(ChangeClass::Config, &prediction(Metric::Turns), &pairs, 3);
assert_eq!(j.disposition, Disposition::Accept, "{:?}", j.disposition);
}
#[test]
fn winning_selection_and_losing_the_holdout_is_a_rejection() {
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() {
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() {
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);
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);
let held = first.iter().filter(|h| **h).count();
assert!((20..80).contains(&held), "{held} of 200 held out");
}
#[test]
fn a_suite_the_baseline_already_passes_cannot_confirm_an_improvement() {
struct Case {
id: String,
was: bool,
now: bool,
}
let cases: Vec<Case> = (0..24)
.map(|i| Case {
id: format!("case-{i}"),
was: is_holdout(&format!("case-{i}"), 3),
now: true,
})
.collect();
fn cost(c: &Case) -> (&str, f64, f64) {
(
c.id.as_str(),
f64::from(u8::from(!c.was)),
f64::from(u8::from(!c.now)),
)
}
let refs: Vec<&Case> = cases.iter().collect();
let (hold, sel): (Vec<&Case>, Vec<&Case>) =
refs.into_iter().partition(|c| is_holdout(&c.id, 3));
let j = judge_slices(ChangeClass::Config, &sel, &hold, cost, |_| (6, 6));
match j.disposition {
Disposition::Propose(ref why) => {
assert!(why.contains("without ever being able to confirm"), "{why}")
}
other => panic!("an all-green holdout was read as confirmation: {other:?}"),
}
}
#[test]
fn a_real_regression_is_not_hidden_behind_a_milder_one_earlier_in_the_list() {
let pairs: Vec<Pair> = corpus(12, 3, |_| {
let mut baseline = run(10, 1, false);
baseline.turns = 10;
baseline.compactions = 0;
baseline.malformed_tool_args = 4;
let mut candidate = run(10, 1, false);
candidate.turns = 5;
candidate.compactions = 2;
candidate.malformed_tool_args = 10;
(baseline, candidate)
});
let j = judge(ChangeClass::Config, &prediction(Metric::Turns), &pairs, 3);
match j.disposition {
Disposition::Reject(ref why) => assert!(why.contains("MalformedArgs"), "{why}"),
other => panic!("the worse finding was hidden behind the milder one: {other:?}"),
}
}
#[test]
fn a_cost_appearing_from_nothing_reaches_a_person_rather_than_being_refused() {
let pairs: Vec<Pair> = corpus(12, 3, |_| {
let mut baseline = run(10, 1, false);
baseline.turns = 10;
baseline.compactions = 0;
let mut candidate = run(10, 1, false);
candidate.turns = 5;
candidate.compactions = 2;
(baseline, candidate)
});
let j = judge(ChangeClass::Config, &prediction(Metric::Turns), &pairs, 3);
match j.disposition {
Disposition::Propose(ref why) => {
assert!(why.contains("rose from"), "{why}");
assert!(why.contains("only a person can tell which"), "{why}");
}
other => panic!("the knob's own effect was treated as a regression: {other:?}"),
}
}
#[test]
fn the_generic_gate_grades_case_outcomes_by_the_same_rules() {
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();
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:#?}");
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() {
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()
}
}
#[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)));
}
#[test]
fn hashing_a_prioritised_pool_yields_a_prioritised_holdout() {
let corpus: Vec<(String, RunStats)> = (0..40)
.map(|i| {
let s = if i % 2 == 0 {
stats(10, 0)
} else {
stats(10, 4)
};
(format!("ep-{i:02}"), s)
})
.collect();
let m = Metric::ToolErrorRate;
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"
);
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"
);
}
#[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);
let j = judge_drawn(ChangeClass::Config, &prediction, &selection, &holdout[..1]);
assert!(matches!(j.disposition, Disposition::Propose(_)));
}
}