Skip to main content

gglib_core/domain/benchmark/tune/
apply.rs

1//! The apply gate: whether a tune run's winner may become a model's
2//! [`Measured`](crate::domain::DefaultsOrigin::Measured) defaults.
3//!
4//! The gate exists because a tuner without one optimises noise: it
5//! ratchets whichever candidate a lucky draw favoured into the catalog and
6//! reports improvement while doing it. Every rule here is the codified form
7//! of a failure this repo has already measured — a +0.082 that did not
8//! replicate, a control that could not degrade, an arm of 45 zeros that
9//! rendered as a score (ADR 0004).
10//!
11//! ## The in-run calibration pair
12//!
13//! A tune run has no A/A arm the way the agentic eval does, so the drift
14//! estimate is built into the candidate list instead: the **incumbent** — an
15//! all-`None` overlay, which resolves through the normal chain and is
16//! therefore exactly what the model does today — runs twice. The gap between
17//! the twins is the run's own noise, measured under the same tasks, the same
18//! server, the same everything. A winner must clear the incumbent's mean by
19//! [`EFFECT_NOISE_RATIO`] times that gap before the gate calls it a winner.
20
21use serde::{Deserialize, Serialize};
22
23use super::result::{CandidateSource, TuneCandidateResult};
24use crate::domain::benchmark::agentic::{EFFECT_NOISE_RATIO, PairedEffect};
25
26/// What an apply attempt decided, and the numbers it decided on.
27///
28/// Refusals are first-class outcomes, not errors: each names the evidence
29/// that was missing or contrary, because "the gate said no" is only useful
30/// if it says what would have changed the answer.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "verdict", rename_all = "snake_case")]
33pub enum ApplyVerdict {
34    /// The winner cleared every gate and may be stored as this model's
35    /// measured defaults.
36    Apply {
37        /// The winning candidate's composite.
38        winner_composite: f64,
39        /// Mean composite of the incumbent pair.
40        incumbent_mean: f64,
41        /// `winner_composite − incumbent_mean`.
42        margin: f64,
43        /// The calibration pair's gap — the run's own drift.
44        drift: f64,
45        /// The winner-versus-incumbent paired comparison.
46        paired: Option<PairedEffect>,
47    },
48    /// The best candidate *is* the incumbent: the model's current defaults
49    /// beat every swept candidate, and there is nothing to apply. Not a
50    /// failure — the run answered its question.
51    IncumbentStands {
52        /// The incumbent pair's mean composite.
53        incumbent_mean: f64,
54    },
55    /// The winner's margin over the incumbent is inside the run's own drift.
56    /// Unresolved, not absent — the fix is more tasks or a re-run, never a
57    /// smaller threshold.
58    WithinDrift {
59        /// `winner_composite − incumbent_mean`.
60        margin: f64,
61        /// The calibration pair's gap.
62        drift: f64,
63    },
64    /// The margin clears the drift, but the per-task paired comparison runs
65    /// the other way — the winner's mean rests on a minority of tasks. A
66    /// mean and its pairs disagreeing is exactly the shape a lucky outlier
67    /// task produces.
68    PairedDisagrees {
69        /// Pairs the winner took.
70        wins: usize,
71        /// Pairs the incumbent took.
72        losses: usize,
73    },
74    /// The run carries no incumbent pair, so nothing calibrates it — a run
75    /// from before the calibration pair existed, or one whose incumbents
76    /// never completed. Nothing can be applied from it.
77    Uncalibrated,
78    /// The winner or an incumbent has runs that never reached the model, so
79    /// their composites are contaminated by a knowable amount and the
80    /// comparison is not trustworthy.
81    Contaminated {
82        /// Unmeasured runs across the compared candidates.
83        unmeasured_runs: usize,
84    },
85}
86
87impl ApplyVerdict {
88    /// Whether this verdict licenses writing the winner.
89    #[must_use]
90    pub const fn applies(&self) -> bool {
91        matches!(self, Self::Apply { .. })
92    }
93
94    /// Why the gate decided this, in one sentence.
95    ///
96    /// Split from [`Display`](std::fmt::Display) because the two surfaces want different
97    /// amounts: a table column wants the numbers, a detail view wants the
98    /// numbers *and* the reasoning. Keeping them apart lets both read from
99    /// one source instead of each restating the gate's rules in its own
100    /// words — which is how three renderers came to disagree about what a
101    /// refusal meant.
102    ///
103    /// Every sentence here says what would resolve the refusal, because a
104    /// gate that only says "no" teaches nobody anything.
105    #[must_use]
106    pub const fn rationale(&self) -> &'static str {
107        match self {
108            Self::Apply { .. } => "The margin cleared the run's own drift and the pairs agreed.",
109            Self::IncumbentStands { .. } => {
110                "No candidate beat the model's current defaults. The run answered its \
111                 question, and the answer is 'change nothing'."
112            }
113            Self::WithinDrift { .. } => {
114                "The winner's margin is inside the run's own drift. Unresolved, not \
115                 absent; more tasks or a re-run resolves it, a smaller threshold never \
116                 does."
117            }
118            Self::PairedDisagrees { .. } => {
119                "The winner's mean rests on a minority of tasks — the lucky-outlier \
120                 shape, refused by the pairs."
121            }
122            Self::Uncalibrated => {
123                "This run has no incumbent calibration pair, so nothing measures its \
124                 drift. Re-run the tune; every new run carries the pair."
125            }
126            Self::Contaminated { .. } => {
127                "Some task runs never reached the model, so the compared scores are \
128                 contaminated. A zero from a dead upstream is not a low score."
129            }
130        }
131    }
132}
133
134/// The verdict's headline: what happened, with the numbers that decided it.
135///
136/// Deliberately unstyled and single-line so every surface can wrap it in its
137/// own presentation. Pair with [`ApplyVerdict::rationale`] where there is
138/// room to explain.
139impl std::fmt::Display for ApplyVerdict {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Self::Apply {
143                winner_composite,
144                incumbent_mean,
145                margin,
146                drift,
147                ..
148            } => write!(
149                f,
150                "applied: winner {winner_composite:.3} over incumbent \
151                 {incumbent_mean:.3}, margin {margin:+.3} against drift {drift:.3}"
152            ),
153            Self::IncumbentStands { incumbent_mean } => {
154                write!(f, "refused: incumbent stands at {incumbent_mean:.3}")
155            }
156            Self::WithinDrift { margin, drift } => {
157                write!(f, "refused: margin {margin:+.3} within drift {drift:.3}")
158            }
159            Self::PairedDisagrees { wins, losses } => {
160                write!(f, "refused: pairs disagree ({wins}W-{losses}L)")
161            }
162            Self::Uncalibrated => write!(f, "refused: uncalibrated run"),
163            Self::Contaminated { unmeasured_runs } => {
164                write!(f, "refused: {unmeasured_runs} unmeasured run(s)")
165            }
166        }
167    }
168}
169
170/// Evaluate a completed tune run's candidates against the apply gate.
171///
172/// `candidates` is the run's full stored list. The winner is the highest
173/// composite among full-suite, measured, non-calibration candidates; the
174/// incumbent pair is found by [`CandidateSource`].
175#[must_use]
176pub fn evaluate_apply(candidates: &[TuneCandidateResult]) -> ApplyVerdict {
177    let incumbents: Vec<&TuneCandidateResult> = candidates
178        .iter()
179        .filter(|c| {
180            matches!(
181                c.source,
182                CandidateSource::Incumbent | CandidateSource::IncumbentCalibration
183            ) && !c.pruned
184        })
185        .collect();
186    let [first, second] = incumbents.as_slice() else {
187        return ApplyVerdict::Uncalibrated;
188    };
189
190    let Some(winner) = candidates
191        .iter()
192        .filter(|c| {
193            !c.pruned
194                && !matches!(
195                    c.source,
196                    CandidateSource::Incumbent | CandidateSource::IncumbentCalibration
197                )
198        })
199        .max_by(|a, b| {
200            a.composite_score
201                .partial_cmp(&b.composite_score)
202                .expect("composites are finite")
203        })
204    else {
205        // Only the incumbent pair survived: the sweep produced nothing to
206        // compare, which is the incumbent standing by default.
207        return ApplyVerdict::IncumbentStands {
208            incumbent_mean: f64::midpoint(first.composite_score, second.composite_score),
209        };
210    };
211
212    let unmeasured_runs = unmeasured(winner) + unmeasured(first) + unmeasured(second);
213    if unmeasured_runs > 0 {
214        return ApplyVerdict::Contaminated { unmeasured_runs };
215    }
216
217    let incumbent_mean = f64::midpoint(first.composite_score, second.composite_score);
218    let drift = (first.composite_score - second.composite_score).abs();
219    let margin = winner.composite_score - incumbent_mean;
220
221    if margin <= 0.0 {
222        return ApplyVerdict::IncumbentStands { incumbent_mean };
223    }
224    if margin < EFFECT_NOISE_RATIO * drift {
225        return ApplyVerdict::WithinDrift { margin, drift };
226    }
227
228    // Direction check: the winner's mean must not rest on a minority of
229    // tasks. Compared against the first incumbent twin — either would do,
230    // and mixing both would double the incumbent's task list against the
231    // winner's single one.
232    let paired = PairedEffect::from_paired_runs(&winner.task_results, &first.task_results);
233    if let Some(p) = &paired
234        && p.losses > p.wins
235    {
236        return ApplyVerdict::PairedDisagrees {
237            wins: p.wins,
238            losses: p.losses,
239        };
240    }
241
242    ApplyVerdict::Apply {
243        winner_composite: winner.composite_score,
244        incumbent_mean,
245        margin,
246        drift,
247        paired,
248    }
249}
250
251/// The winner a verdict of [`ApplyVerdict::Apply`] refers to.
252///
253/// Re-derived by the same rule `evaluate_apply` uses, so the applier and the
254/// gate cannot disagree about which candidate won.
255#[must_use]
256pub fn winning_candidate(candidates: &[TuneCandidateResult]) -> Option<&TuneCandidateResult> {
257    candidates
258        .iter()
259        .filter(|c| {
260            !c.pruned
261                && !matches!(
262                    c.source,
263                    CandidateSource::Incumbent | CandidateSource::IncumbentCalibration
264                )
265        })
266        .max_by(|a, b| {
267            a.composite_score
268                .partial_cmp(&b.composite_score)
269                .expect("composites are finite")
270        })
271}
272
273fn unmeasured(candidate: &TuneCandidateResult) -> usize {
274    candidate
275        .task_results
276        .iter()
277        .filter(|r| !r.is_measured())
278        .count()
279}
280
281/// The durable record of an apply, stored on the run row so
282/// `gglib model explain`'s "measured by a tune sweep" can be traced to the
283/// numbers that licensed it.
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct ApplyRecord {
286    /// The verdict that licensed the write, with its numbers.
287    pub verdict: ApplyVerdict,
288    /// The applied sampling overlay, exactly as stored on the model.
289    /// `None` on a refusal record — the verdict says why nothing was
290    /// written. (Optional since refusals began leaving records; an apply
291    /// written before that always carries `Some`.)
292    #[serde(default)]
293    pub applied_config: Option<crate::domain::InferenceConfig>,
294    /// The defaults the apply displaced, exactly as they were stored.
295    ///
296    /// What makes an apply reversible without archaeology: a signal-driven
297    /// sweep that made things worse can be undone from the run row alone.
298    /// `None` on records written before the field existed, and a real
299    /// `Some(None)`-shaped absence is representable — a model that had no
300    /// stored defaults restores to having none.
301    #[serde(default)]
302    pub prior_defaults: Option<Option<crate::domain::InferenceConfig>>,
303    /// The origin the displaced defaults carried.
304    #[serde(default)]
305    pub prior_origin: Option<Option<crate::domain::DefaultsOrigin>>,
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::domain::InferenceConfig;
312    use crate::domain::benchmark::tune::result::{GeneratedOutput, TuneTaskResult};
313    use crate::domain::benchmark::tune::task::TaskCategory;
314
315    fn task(id: &str, score: f64) -> TuneTaskResult {
316        TuneTaskResult {
317            task_id: id.to_owned(),
318            category: TaskCategory::SingleCall,
319            passed: score >= 1.0,
320            tool_match_score: score,
321            loop_detected: false,
322            stagnation_detected: false,
323            iterations: 1,
324            latency_ms: 10,
325            completion_tokens: Some(100),
326            time_to_first_tool_call_ms: Some(5),
327            detail: None,
328            unmeasured: None,
329            transport_retries: 0,
330            generated: GeneratedOutput::default(),
331        }
332    }
333
334    fn candidate(source: CandidateSource, scores: &[f64]) -> TuneCandidateResult {
335        #[allow(clippy::cast_precision_loss)]
336        let composite = scores.iter().sum::<f64>() / scores.len() as f64;
337        TuneCandidateResult {
338            config: InferenceConfig::default(),
339            source,
340            task_results: scores
341                .iter()
342                .enumerate()
343                .map(|(i, s)| task(&format!("t{i}"), *s))
344                .collect(),
345            composite_score: composite,
346            pruned: false,
347            tg_tps: None,
348        }
349    }
350
351    fn incumbent_pair(scores: &[f64], twin_scores: &[f64]) -> Vec<TuneCandidateResult> {
352        vec![
353            candidate(CandidateSource::Incumbent, scores),
354            candidate(CandidateSource::IncumbentCalibration, twin_scores),
355        ]
356    }
357
358    /// The headline path: a winner clearly above the incumbent pair, with
359    /// the pairs agreeing, applies.
360    #[test]
361    fn a_clear_winner_applies() {
362        let mut candidates = incumbent_pair(&[0.5, 0.5, 0.5], &[0.52, 0.5, 0.5]);
363        candidates.push(candidate(CandidateSource::UserGrid, &[0.9, 0.9, 0.9]));
364        let verdict = evaluate_apply(&candidates);
365        assert!(verdict.applies(), "{verdict:?}");
366    }
367
368    /// The gate's core rule: a margin inside the run's own drift is
369    /// unresolved, and unresolved never applies.
370    #[test]
371    fn a_margin_within_drift_is_refused() {
372        // Incumbent twins 0.3 apart: the run is very noisy.
373        let mut candidates = incumbent_pair(&[0.5, 0.5, 0.6], &[0.8, 0.8, 0.9]);
374        // The winner beats the incumbent mean by less than 2× that gap.
375        candidates.push(candidate(CandidateSource::UserGrid, &[0.9, 0.8, 0.9]));
376        match evaluate_apply(&candidates) {
377            ApplyVerdict::WithinDrift { margin, drift } => {
378                assert!(margin < EFFECT_NOISE_RATIO * drift, "{margin} vs {drift}");
379            }
380            other => panic!("expected WithinDrift, got {other:?}"),
381        }
382    }
383
384    /// A run without the calibration pair — every run recorded before the
385    /// pair existed — cannot be applied from, whatever its scores say.
386    #[test]
387    fn a_run_without_the_incumbent_pair_is_uncalibrated() {
388        let candidates = vec![candidate(CandidateSource::UserGrid, &[1.0, 1.0, 1.0])];
389        assert_eq!(evaluate_apply(&candidates), ApplyVerdict::Uncalibrated);
390    }
391
392    /// A winner that does not beat the incumbent is the incumbent standing —
393    /// a successful run whose answer is "change nothing".
394    #[test]
395    fn an_unbeaten_incumbent_stands() {
396        let mut candidates = incumbent_pair(&[0.9, 0.9, 0.9], &[0.9, 0.9, 0.9]);
397        candidates.push(candidate(CandidateSource::UserGrid, &[0.5, 0.5, 0.5]));
398        assert!(matches!(
399            evaluate_apply(&candidates),
400            ApplyVerdict::IncumbentStands { .. }
401        ));
402    }
403
404    /// Unmeasured runs in either side of the comparison poison it: a zero
405    /// from a dead upstream is not a low score (the 45-zeros lesson,
406    /// ADR 0004).
407    #[test]
408    fn contaminated_candidates_are_refused() {
409        let mut candidates = incumbent_pair(&[0.5, 0.5, 0.5], &[0.5, 0.5, 0.5]);
410        let mut winner = candidate(CandidateSource::UserGrid, &[0.9, 0.9, 0.9]);
411        winner.task_results[1].unmeasured = Some("upstream died".to_owned());
412        candidates.push(winner);
413        assert!(matches!(
414            evaluate_apply(&candidates),
415            ApplyVerdict::Contaminated { unmeasured_runs: 1 }
416        ));
417    }
418
419    /// A mean carried by one outlier task while the incumbent wins the rest
420    /// is refused: the pairs outvote the mean.
421    #[test]
422    fn a_minority_winner_is_refused_by_the_pairs() {
423        // The winner's mean (0.55) clears the incumbent mean (0.5) and the
424        // twins' zero drift — but it rests entirely on one outlier task
425        // while the incumbent takes the other three.
426        let mut candidates = incumbent_pair(&[0.5, 0.5, 0.5, 0.5], &[0.5, 0.5, 0.5, 0.5]);
427        candidates.push(candidate(CandidateSource::UserGrid, &[1.0, 0.4, 0.4, 0.4]));
428        match evaluate_apply(&candidates) {
429            ApplyVerdict::PairedDisagrees { wins, losses } => {
430                assert_eq!((wins, losses), (1, 3));
431            }
432            other => panic!("expected PairedDisagrees, got {other:?}"),
433        }
434    }
435
436    /// A pruned candidate never wins: its composite covers only the
437    /// pre-screen tasks and is not comparable with a full-suite score.
438    #[test]
439    fn a_pruned_candidate_cannot_win() {
440        let mut candidates = incumbent_pair(&[0.5, 0.5, 0.5], &[0.5, 0.5, 0.5]);
441        let mut pruned = candidate(CandidateSource::UserGrid, &[1.0, 1.0, 1.0]);
442        pruned.pruned = true;
443        candidates.push(pruned);
444        assert!(matches!(
445            evaluate_apply(&candidates),
446            ApplyVerdict::IncumbentStands { .. }
447        ));
448    }
449}