gglib_core/domain/benchmark/agentic.rs
1//! Raw-vs-gglib A/B agentic evaluation: config and report types.
2//!
3//! The eval answers one question with numbers: *what does routing a small
4//! model through the gglib pipeline actually buy in agentic behaviour?* It
5//! runs the same task suite the tune sweep uses — real `AgentLoop`, scripted
6//! BFCL-style tasks — twice against the same loaded model:
7//!
8//! - **raw**: the request pipeline bypassed entirely. No sampling
9//! resolution (the server's own defaults apply), no capability shaping,
10//! no dialect normalization, no grammar — what a client pointed straight
11//! at llama-server experiences.
12//! - **gglib**: the full pipeline, exactly as the proxy runs it — per-model
13//! sampling defaults, capability-aware shaping, dialect parsing, and
14//! decode-time grammar enforcement where a task demands a tool call.
15//!
16//! The per-axis deltas in the [`AgenticEvalReport`] are the product: the
17//! measured difference in tool-call accuracy, loop avoidance, and task
18//! completion, on this model, on this machine.
19//!
20//! Two further arms exist to keep those deltas honest, and neither is a
21//! measurement of the pipeline:
22//!
23//! - **`raw_replicate`** ([`EvalArm::RawReplicate`]) runs the raw arm a second
24//! time on a *disjoint* seed set. Nothing differs between it and the raw arm
25//! except which seeds were drawn, so whatever gap it opens is the eval's own
26//! drift — the floor a raw-versus-gglib delta has to clear before it means
27//! anything. An A/A test.
28//! - **`control`** ([`EvalArm::Control`]) runs the gglib pipeline with sampling
29//! deliberately broken, and must score far below it. It answers the opposite
30//! question: not *is this difference real* but *could this apparatus have
31//! seen a difference at all*.
32//!
33//! They answer different failures and neither substitutes for the other. A
34//! control that moves 0.5 says the eval can detect a large change; it says
35//! nothing about whether it can resolve a 0.08 one, which is what the A/A arm
36//! is for.
37
38use serde::{Deserialize, Serialize};
39
40use super::tune::config::ScoreWeights;
41use super::tune::result::{GeneratedOutput, TuneTaskResult};
42use super::tune::task::{TaskCategory, TaskSuite};
43
44/// Configuration for one A/B agentic eval run.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AgenticEvalConfig {
47 /// Database ID of the model to evaluate.
48 pub model_id: i64,
49 /// Task suite both arms run — the same schema the tune sweep uses.
50 pub task_suite: TaskSuite,
51 /// Weights for each arm's composite score.
52 ///
53 /// `None` means "the server decides"; see [`TuneConfig::weights`], which
54 /// also explains why `skip_serializing_if` is required rather than
55 /// cosmetic.
56 ///
57 /// [`TuneConfig::weights`]: super::tune::config::TuneConfig::weights
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub weights: Option<ScoreWeights>,
60 /// Context size override (tokens). `None` resolves through the harness's
61 /// own chain (model server defaults → global setting → hardcoded default),
62 /// which deliberately stops short of the fitted rung a real launch reaches
63 /// — so a benchmark taken with nothing configured is taken at the floor.
64 /// Recorded in ADR 0009's amendment; not the serving path's chain.
65 #[serde(default)]
66 pub ctx_size: Option<u64>,
67 /// RNG seeds to repeat every task under, once each.
68 ///
69 /// # Why more than one
70 ///
71 /// A single sample per task is not a measurement of a model, it is one
72 /// draw from its output distribution. Two runs of the *identical* raw
73 /// configuration have scored `0.728` and `0.543` on this suite — a gap
74 /// wider than most of the effects the eval exists to detect. Averaging a
75 /// handful of seeds is what separates a real difference from that spread.
76 ///
77 /// Seeded rather than merely repeated, so a surprising number can be
78 /// re-run and reproduced instead of chased. An empty list means one
79 /// unseeded run per task, which is the pre-multi-seed behaviour and is
80 /// kept reachable deliberately — it is the fastest smoke test.
81 #[serde(default = "default_seeds")]
82 pub seeds: Vec<u32>,
83 /// Whether to run the positive control arm. See [`EvalArm::Control`].
84 #[serde(default = "default_include_control")]
85 pub include_control: bool,
86 /// Whether to run the A/A arm. See [`EvalArm::RawReplicate`].
87 ///
88 /// On by default, and cheap: the raw arm is the fastest of the three, so
89 /// repeating it costs a fraction of what the control does and is the only
90 /// thing in the report that speaks to the *size* of an effect rather than
91 /// its direction.
92 #[serde(default = "default_replicate_raw")]
93 pub replicate_raw: bool,
94 /// How many A/A pairs to run. See [`EvalArm::RawReplicate`].
95 ///
96 /// `1` is the historical single-pair behaviour and the default. A single
97 /// pair estimates the eval's drift from one degree of freedom — enough to
98 /// stop a delta inside its own noise being called a finding, and not
99 /// enough to say how noisy the eval actually is. Every additional pair
100 /// re-runs the raw arm on another derived, disjoint seed set, and the
101 /// drift estimate becomes the mean pairwise gap over all replicate runs
102 /// plus the primary — which is the "more pairs" the
103 /// [`EFFECT_NOISE_RATIO`] doc has always named as the honest
104 /// strengthening.
105 #[serde(default = "default_replicate_pairs")]
106 pub replicate_pairs: usize,
107 /// How many of [`Self::seeds`] the positive control repeats, from the
108 /// front. Clamped into `1..=seeds.len()`.
109 ///
110 /// # Why this is not the full seed set
111 ///
112 /// Because the control is the most expensive arm in the eval by an order
113 /// of magnitude, and it does not need the precision. Measured on
114 /// Qwen3.5-4B: broken sampling makes the model ramble, so the control took
115 /// **161 of one run's 174 wall-clock minutes** and generated 5× the tokens
116 /// of the two real arms combined.
117 ///
118 /// It can afford to be imprecise because of what it is asked. The two real
119 /// arms are being compared to each other and need every seed they can get;
120 /// the control only has to clear [`CONTROL_MIN_COMPOSITE_GAP`], and the gap
121 /// it actually opens is an order of magnitude above that threshold. Paying
122 /// five seeds to resolve a 0.5 gap more precisely buys nothing the report
123 /// reads.
124 #[serde(default = "default_control_seeds")]
125 pub control_seeds: usize,
126}
127
128/// The seeds an eval uses when its config names none.
129///
130/// Three, because it is the smallest count that can distinguish "these two
131/// arms differ" from "one of them had an unlucky draw", and each extra seed
132/// costs a full pass over the suite.
133pub const DEFAULT_SEEDS: [u32; 3] = [12345, 67890, 11111];
134
135fn default_seeds() -> Vec<u32> {
136 DEFAULT_SEEDS.to_vec()
137}
138
139const fn default_include_control() -> bool {
140 true
141}
142
143const fn default_replicate_raw() -> bool {
144 true
145}
146
147const fn default_control_seeds() -> usize {
148 1
149}
150
151const fn default_replicate_pairs() -> usize {
152 1
153}
154
155/// Offset added to each primary seed to derive the A/A arm's seeds.
156///
157/// The 32-bit golden-ratio constant, chosen for nothing but being a fixed,
158/// well-spread, unremarkable number. Derived rather than drawn because the A/A
159/// arm has to be as reproducible as the arms it is calibrating: a noise floor
160/// that changes every run cannot be compared against anything.
161pub const REPLICATE_SEED_OFFSET: u32 = 0x9E37_79B9;
162
163/// The seed set the A/A arm runs, derived from the primary one.
164///
165/// # Why the seeds must differ
166///
167/// This is the whole design of the arm. Re-running the *same* seeds would
168/// measure how reproducible a fixed seed is — which, given a deterministic
169/// decode, is approximately "perfectly", and would report a noise floor near
170/// zero. That number is true and useless: the primary comparison's precision
171/// is not limited by whether seed `12345` replays, it is limited by *which five
172/// seeds happened to be drawn*. So the replicate draws five different ones and
173/// measures exactly that.
174///
175/// A pathological seed list can still overlap — `[1, 1 + OFFSET]` maps onto
176/// itself by one element — so the seeds the replicate actually used are
177/// recorded in [`AgenticEvalReport::replicate_seeds`] rather than left implicit.
178#[must_use]
179pub fn replicate_seeds(seeds: &[u32]) -> Vec<u32> {
180 replicate_seed_set(seeds, 1)
181}
182
183/// The seed set for A/A pair `pair` (1-based): the primary seeds offset by
184/// `pair` strides of [`REPLICATE_SEED_OFFSET`].
185///
186/// Pair 1 is exactly [`replicate_seeds`], so a multi-pair run's first pair
187/// reproduces the single-pair run's numbers. Strides of a fixed constant
188/// rather than fresh draws for the same reason the offset itself is fixed: a
189/// noise floor that changes every run cannot be compared against anything.
190#[must_use]
191pub fn replicate_seed_set(seeds: &[u32], pair: u32) -> Vec<u32> {
192 seeds
193 .iter()
194 .map(|seed| seed.wrapping_add(REPLICATE_SEED_OFFSET.wrapping_mul(pair)))
195 .collect()
196}
197
198/// The temperature the control arm forces.
199///
200/// Chosen to be unambiguously bad for structured output rather than
201/// marginally worse: the control's job is to produce a difference so large
202/// that failing to detect it means the apparatus is not measuring sampling at
203/// all. A subtle degradation would leave "no difference" ambiguous between a
204/// broken harness and a robust model, which is the exact ambiguity this exists
205/// to remove.
206pub const CONTROL_TEMPERATURE: f32 = 2.0;
207
208/// `top_k` the control arm forces. `0` disables the cut entirely.
209pub const CONTROL_TOP_K: i32 = 0;
210
211/// `top_p` the control arm forces. `1.0` keeps the whole nucleus.
212pub const CONTROL_TOP_P: f32 = 1.0;
213
214/// `min_p` the control arm forces. `0.0` disables the tail cut.
215pub const CONTROL_MIN_P: f32 = 0.0;
216
217/// The sampling the control arm applies, on top of a request's seed.
218///
219/// # Why the temperature alone was not enough
220///
221/// The first version of this control set only [`CONTROL_TEMPERATURE`], and it
222/// **failed to degrade anything** — measured on Qwen3.5-4B, it scored *above*
223/// both real arms. The reason is the sampler chain's order, which [ADR 0003]
224/// finding 5 measured: llama.cpp applies the truncation samplers *before*
225/// temperature. With a `reasoning` recipe's `top_k: 20` and `top_p: 0.95`
226/// already in force, temperature 2.0 was only flattening a distribution over
227/// twenty surviving tokens — a much tamer change than the number suggests.
228///
229/// So the control disables every truncation sampler as well. A temperature
230/// that cannot be absorbed by a `top_k` running ahead of it is the only kind
231/// that demonstrates anything.
232///
233/// # It differs from the gglib arm in more than one value, and that is fine
234///
235/// An earlier comment here claimed the control differed in exactly the
236/// temperature, so a gap could only be that. That was already untrue: naming a
237/// temperature claims the coupled trio, so the control's `presence_penalty`
238/// and `repeat_penalty` fall to the class floor rather than matching the
239/// model's recipe. Isolating one variable is a job for an ablation; this
240/// arm's job is to be *large and known-bad*, and breadth serves that.
241///
242/// [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
243#[must_use]
244pub const fn control_sampling() -> (f32, i32, f32, f32) {
245 (
246 CONTROL_TEMPERATURE,
247 CONTROL_TOP_K,
248 CONTROL_TOP_P,
249 CONTROL_MIN_P,
250 )
251}
252
253/// Which arm a task ran under.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum EvalArm {
257 /// Pipeline bypassed — bare llama-server behaviour.
258 Raw,
259 /// The full gglib request/response pipeline.
260 Gglib,
261 /// **A/A control.** The raw arm again, on a disjoint seed set.
262 ///
263 /// Nothing about the request differs from [`Self::Raw`] — same bypass, same
264 /// tasks, same machine, same loaded model — so any gap between the two is
265 /// the eval measuring itself. That gap is the floor a raw-versus-gglib
266 /// delta has to clear, and without it a small delta has two readings that
267 /// the report cannot separate: the pipeline helped a little, or five seeds
268 /// is not enough seeds.
269 ///
270 /// It answers a strictly different question from [`Self::Control`]. The
271 /// control establishes that a *large* change registers; this establishes
272 /// how large a change has to be before it registers as anything but drift.
273 /// A run carrying only the control can say "the apparatus works" about an
274 /// effect it has no ability to resolve.
275 RawReplicate,
276 /// **Positive control.** The gglib pipeline with the temperature forced to
277 /// [`CONTROL_TEMPERATURE`], which should sample visibly worse.
278 ///
279 /// It exists to answer a question the other two arms cannot: *can this
280 /// apparatus detect a sampling change at all?* A raw-versus-gglib run
281 /// showing no difference has two explanations — the pipeline does not help,
282 /// or the harness cannot see — and nothing in that run distinguishes them.
283 ///
284 /// This arm is a deliberate, large, known-bad change. If it does **not**
285 /// score below the gglib arm, the apparatus failed to move under a
286 /// difference that should be impossible to miss, and no other number in
287 /// the report can be believed. That is the same discipline
288 /// [ADR 0004](https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md)
289 /// applies to its instruments: a comparison in which nothing could have
290 /// varied, reporting that nothing varied, is not evidence.
291 Control,
292}
293
294impl std::fmt::Display for EvalArm {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 match self {
297 Self::Raw => write!(f, "raw"),
298 Self::Gglib => write!(f, "gglib"),
299 Self::RawReplicate => write!(f, "raw (A/A)"),
300 Self::Control => write!(f, "control"),
301 }
302 }
303}
304
305/// One arm's aggregate scores across the task suite.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
308pub struct ArmScores {
309 /// Mean AST-style tool-call match score, `0.0`–`1.0`.
310 pub tool_accuracy: f64,
311 /// Fraction of *loop-eligible* tasks that triggered neither the loop nor
312 /// the stagnation guard.
313 ///
314 /// `None` when no task in this arm ever reached a second tool-call batch,
315 /// so the guards had nothing to fire on: the axis was not measured, which
316 /// is distinct from a perfect `1.0`. Read it together with
317 /// [`Self::loop_eligible`], which is its denominator.
318 #[serde(default)]
319 pub loop_avoidance: Option<f64>,
320 /// How many of this arm's tasks were loop-eligible — the sample size
321 /// behind [`Self::loop_avoidance`].
322 #[serde(default)]
323 pub loop_eligible: usize,
324 /// Fraction of tasks passed outright.
325 pub task_completion: f64,
326 /// Weighted composite of the axes above, over whichever of them were
327 /// measured. An unmeasured loop-avoidance axis claims no weight rather
328 /// than scoring zero.
329 pub composite: f64,
330 /// Completion-token throughput (tokens per wall-clock second, pre-fill
331 /// included). `None` when the upstream reported no usage.
332 pub tg_tps: Option<f64>,
333 /// Total completion tokens generated across the whole suite. `None` when
334 /// no task reported usage.
335 ///
336 /// Reported beside the composite and never folded into it: token cost is
337 /// what the quality axes cannot see, but it is also hardware- and
338 /// model-specific in a way that would make a single blended score
339 /// incomparable across machines.
340 #[serde(default)]
341 #[cfg_attr(feature = "ts-bindings", ts(type = "number | null"))]
342 pub total_completion_tokens: Option<u64>,
343 /// Total wall-clock milliseconds across every task in the suite,
344 /// unfiltered — the honest cost of running it.
345 ///
346 /// Report this as "what the suite cost"; never compare two arms with it.
347 /// A run that stalled and timed out contributes its whole timeout here, so
348 /// this figure answers "how long did I wait" rather than "how fast is the
349 /// pipeline". [`Self::measured_wall_ms`] answers the second.
350 #[serde(default)]
351 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
352 pub total_wall_ms: u64,
353 /// Wall-clock milliseconds across the runs that reached the model.
354 ///
355 /// The comparable figure, and the one every ratio is taken from. It shares
356 /// its population with [`Self::tg_tps`] and
357 /// [`Self::mean_time_to_first_tool_call_ms`], which already filtered this
358 /// way — the efficiency table used to print those beside an unfiltered
359 /// wall time, so two of its rows described different sets of runs while
360 /// looking like one table.
361 #[serde(default)]
362 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
363 pub measured_wall_ms: u64,
364 /// Mean time to the first tool call, over the tasks that made one. `None`
365 /// when no task in the arm called a tool.
366 ///
367 /// **Read this beside [`Self::median_time_to_first_tool_call_ms`], never
368 /// alone.** The population is not unimodal. On 2026-08-29 one arm reached
369 /// its first call in about a second on most tasks and after roughly 950
370 /// *seconds* on five of them; the mean of that is ~94s, which describes
371 /// neither group and no individual run. The mean is kept because a large
372 /// gap between it and the median is itself the finding.
373 #[serde(default)]
374 pub mean_time_to_first_tool_call_ms: Option<f64>,
375 /// Median time to the first tool call, over the tasks that made one.
376 ///
377 /// The typical run, which the mean stops describing the moment a handful of
378 /// runs generate for a quarter of an hour. Reported alongside rather than
379 /// instead of the mean: the median alone would have hidden those five runs
380 /// as effectively as the mean misrepresented them, and the pair is what
381 /// makes the spread visible.
382 #[serde(default)]
383 pub median_time_to_first_tool_call_ms: Option<f64>,
384 /// How many seeds every task was repeated under.
385 ///
386 /// The sample size behind every mean above, and the thing that makes them
387 /// comparable across runs. A composite from one seed and a composite from
388 /// five are not the same measurement, and a report that renders them
389 /// identically invites exactly the mistake this eval exists to prevent.
390 ///
391 /// `1` on a legacy row, which is what it was.
392 #[serde(default = "one")]
393 pub seeds: usize,
394 /// Total task runs behind these scores — `tasks × seeds`.
395 #[serde(default)]
396 pub runs: usize,
397 /// How many of those runs never reached the model, and therefore
398 /// contributed a zero that measures nothing.
399 ///
400 /// See [`TuneTaskResult::unmeasured`]. An arm where this equals
401 /// [`Self::runs`] is not a low score — it is an empty column, and the eval
402 /// refuses to report one rather than rendering it as an arm that did
403 /// badly. Anything between `1` and `runs` contaminates every mean above by
404 /// an amount this number is the only record of.
405 #[serde(default)]
406 pub unmeasured_runs: usize,
407 /// How many attempts this arm threw away to transport failures and retried.
408 ///
409 /// Distinct from [`Self::unmeasured_runs`] in both direction and meaning: a
410 /// retry that worked leaves this non-zero and `unmeasured_runs` at zero, so
411 /// an arm can be fully measured and still have been fighting the upstream
412 /// the whole way. A report that showed only the survivors would call that
413 /// arm clean.
414 ///
415 /// Summed over runs, so one run retried twice and two runs retried once
416 /// both read `2`. See [`TuneTaskResult::transport_retries`].
417 #[serde(default)]
418 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
419 pub transport_retries: u32,
420 /// What this arm generated, summed over its **measured** runs.
421 ///
422 /// Aggregation differs per field, and deliberately: character counts,
423 /// `llm_calls` and `system_warnings` are sums over the arm, while
424 /// [`GeneratedOutput::max_tool_calls_in_batch`] is the arm-wide **maximum**
425 /// — a single runaway batch is the thing worth seeing, and a mean would
426 /// dissolve it into 63 ordinary runs.
427 ///
428 /// Restricted to measured runs for the reason [`Self::measured_wall_ms`]
429 /// gives: a run that never reached the model generated nothing, and folding
430 /// its zeros in would understate the arm's output exactly where the arm was
431 /// least healthy.
432 #[serde(default)]
433 pub generated: GeneratedOutput,
434}
435
436const fn one() -> usize {
437 1
438}
439
440/// Why an arm-level delta is not reported.
441///
442/// A separate state rather than a zero or a silently-diluted number, for the
443/// reason ADR 0004's decision 3 gives about blindness: a difference that could
444/// not be taken and one that came out small license different actions, and a
445/// surface that renders them alike destroys the distinction at the last step.
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(tag = "kind", rename_all = "snake_case")]
448#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
449pub enum DeltaWithheld {
450 /// One or both arms carry runs that never reached the model, so every mean
451 /// on them is pulled toward zero by scores that measure nothing.
452 ///
453 /// Measured: five such runs in the 2026-08-28 eval moved the gglib arm's
454 /// tool accuracy from 0.966 to 0.889 and its wall time from 553s to 3553s,
455 /// and the report printed the resulting −0.058 composite as its headline
456 /// under a warning saying those arms were floors rather than measurements.
457 /// Both statements were on the same screen; only one of them was read.
458 ContaminatedByUnmeasuredRuns {
459 /// Unmeasured runs in the raw arm.
460 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
461 raw: usize,
462 /// Unmeasured runs in the gglib arm.
463 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
464 gglib: usize,
465 },
466}
467
468/// Per-axis difference, `gglib − raw`. Positive means gglib scored higher.
469///
470/// Every axis is `Option`: an arm-level difference is only meaningful when both
471/// arms were measured over the same runs and the same axes. See
472/// [`ArmDelta::withheld`], and prefer [`PairedEffect`] — which drops the
473/// affected pairs instead of diluting everything — whenever it is available.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
476pub struct ArmDelta {
477 /// Tool-accuracy difference. `None` when [`Self::withheld`] is set.
478 #[serde(default)]
479 pub tool_accuracy: Option<f64>,
480 /// Loop-avoidance difference.
481 ///
482 /// `None` unless *both* arms measured the axis — a difference against an
483 /// arm that never risked a loop would be arithmetic on a number that was
484 /// never observed.
485 #[serde(default)]
486 pub loop_avoidance: Option<f64>,
487 /// Task-completion difference. `None` when [`Self::withheld`] is set.
488 #[serde(default)]
489 pub task_completion: Option<f64>,
490 /// Composite-score difference, taken over the axes **both** arms measured.
491 ///
492 /// Each arm's own [`ArmScores::composite`] is renormalized over whichever
493 /// axes that arm measured, so two arms can carry composites on different
494 /// scales — an arm with no loop-eligible run divides by 0.6 where an arm
495 /// with one divides by 0.9. Subtracting those directly measures the scale.
496 /// The 2026-08-28 eval did exactly that: the raw arm's free `1.0` on an
497 /// axis the gglib arm could not be scored on was worth about half the
498 /// reported gap.
499 ///
500 /// `None` when [`Self::withheld`] is set.
501 #[serde(default)]
502 pub composite: Option<f64>,
503 /// Why the axis differences above are absent, when they are.
504 #[serde(default)]
505 pub withheld: Option<DeltaWithheld>,
506 /// Per-run wall-time speedup, `raw ÷ gglib`. Above `1.0` means gglib got
507 /// through the same work faster.
508 ///
509 /// A ratio rather than a difference, for two reasons: lower is better
510 /// here, so a plain subtraction would invert this struct's "positive means
511 /// gglib did better" convention; and the magnitudes are multiplicative —
512 /// a 230× gap reads as `230.0`, not as `-1099737` milliseconds. `None`
513 /// when the gglib arm recorded no measured wall time to divide by.
514 ///
515 /// Taken **per measured run** on both sides. Summed totals put the two arms
516 /// on different denominators the moment either loses a run, and a run lost
517 /// to a timeout contributes the timeout rather than nothing: the
518 /// 2026-08-28 eval reported `0.2×` — 84% of which was five stalled runs
519 /// waiting out a ten-minute deadline — for an arm that was in fact about
520 /// 1.2× faster on the work it actually did.
521 #[serde(default)]
522 pub wall_time_speedup: Option<f64>,
523 /// Per-run completion-token ratio, `raw ÷ gglib`. Above `1.0` means gglib
524 /// reached the same outcome on fewer generated tokens. `None` when either
525 /// arm generated nothing measurable.
526 ///
527 /// Per measured run for the same reason as [`Self::wall_time_speedup`]: the
528 /// summed form divided one arm's 63-run total by the other's 58-run total
529 /// and reported `1.48×` where the per-run figure is `1.36×`.
530 #[serde(default)]
531 pub completion_token_ratio: Option<f64>,
532}
533
534/// One task's outcome under both arms, for drill-down.
535///
536/// Both sides carry **one entry per seed**, in seed order, rather than a single
537/// result. Collapsing them to a representative run would hide the thing a
538/// multi-seed eval is for: a task that passes 3/3 under one arm and 1/3 under
539/// the other is a different finding from one that passes 3/3 versus 0/3, and
540/// both render as "passed / failed" once the per-seed detail is gone.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
543pub struct AgenticTaskComparison {
544 /// Task identifier from the suite.
545 pub task_id: String,
546 /// The task's BFCL-style category.
547 pub category: TaskCategory,
548 /// Per-seed results under the raw arm, in seed order.
549 pub raw: Vec<TuneTaskResult>,
550 /// Per-seed results under the gglib arm, in seed order.
551 pub gglib: Vec<TuneTaskResult>,
552}
553
554impl AgenticTaskComparison {
555 /// How many of this task's seeds passed under each arm.
556 ///
557 /// The per-task view of stability: `(2, 3)` means two of three seeds
558 /// passed, which is a materially different claim from a bare `passed:
559 /// true` taken from whichever seed happened to run first.
560 #[must_use]
561 pub fn pass_counts(&self) -> (usize, usize) {
562 (
563 self.raw.iter().filter(|r| r.passed).count(),
564 self.gglib.iter().filter(|r| r.passed).count(),
565 )
566 }
567
568 /// Whether either arm disagreed with itself across seeds.
569 ///
570 /// A task that flips between passing and failing on identical
571 /// configuration is where suite-level variance comes from, and naming it
572 /// per task is what turns "the numbers moved" into something actionable.
573 #[must_use]
574 pub fn is_unstable(&self) -> bool {
575 let mixed = |runs: &[TuneTaskResult]| {
576 runs.iter().any(|r| r.passed) && runs.iter().any(|r| !r.passed)
577 };
578 mixed(&self.raw) || mixed(&self.gglib)
579 }
580}
581
582/// The complete A/B report — the leaderboard interchange format.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
585pub struct AgenticEvalReport {
586 /// Model name as stored in the catalog.
587 pub model_name: String,
588 /// Quantization label (e.g. `Q4_K_M`), when known.
589 pub quantization: Option<String>,
590 /// Parameter count in billions.
591 pub param_count_b: f64,
592 /// Context size both arms ran at, in tokens.
593 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
594 pub ctx_size: u64,
595 /// Aggregate scores under the raw arm.
596 pub raw: ArmScores,
597 /// Aggregate scores under the gglib arm.
598 pub gglib: ArmScores,
599 /// Per-axis `gglib − raw` differences.
600 pub delta: ArmDelta,
601 /// Per-task drill-down, one entry per suite task.
602 pub tasks: Vec<AgenticTaskComparison>,
603 /// The seeds every task ran under, in order. Empty on a legacy row and on
604 /// an explicitly unseeded run.
605 #[serde(default)]
606 pub seeds: Vec<u32>,
607 /// Scores under the positive control arm, when it ran.
608 ///
609 /// Read [`Self::control_verdict`] rather than these numbers directly: what
610 /// matters is not the control's score but whether it *differs*. Its
611 /// [`ArmScores::seeds`] is usually smaller than the real arms' — see
612 /// [`AgenticEvalConfig::control_seeds`] — so its composite is a coarser
613 /// number than the ones it sits beside.
614 #[serde(default)]
615 pub control: Option<ArmScores>,
616 /// Scores under the A/A arm — the raw pipeline again, different seeds.
617 ///
618 /// Read [`Self::effect_verdict`] rather than this directly: the number that
619 /// matters is its *distance* from [`Self::raw`], not its own value.
620 #[serde(default)]
621 pub raw_replicate: Option<ArmScores>,
622 /// The seeds the A/A arm ran under. Empty when it did not run, and when it
623 /// ran unseeded.
624 ///
625 /// Recorded rather than derived at read time so an overlap with
626 /// [`Self::seeds`] is visible in the report instead of having to be
627 /// recomputed from [`replicate_seeds`].
628 #[serde(default)]
629 pub replicate_seeds: Vec<u32>,
630 /// Every A/A pair's scores, in pair order, when more than one ran.
631 ///
632 /// [`Self::raw_replicate`] stays populated with the first pair so a
633 /// single-pair report — and every report written before this field —
634 /// reads exactly as it always did. A legacy row deserializes this empty,
635 /// and [`Self::noise_floor`] falls back to the single pair.
636 #[serde(default)]
637 pub raw_replicates: Vec<ArmScores>,
638 /// The seed set behind each entry of [`Self::raw_replicates`].
639 #[serde(default)]
640 pub replicate_seed_sets: Vec<Vec<u32>>,
641 /// The paired per-`(task, seed)` comparison, computed at assembly.
642 ///
643 /// Stored rather than derived-only, unlike the verdicts: those re-derive
644 /// from two floats in any language, while this one carries a rank test
645 /// nobody should maintain twice. [`Self::paired_effect`] re-derives it
646 /// from the drill-down for reports written before the field existed.
647 #[serde(default)]
648 pub paired: Option<PairedEffect>,
649}
650
651/// The smallest composite gap the control arm must open for the apparatus to
652/// have demonstrably moved.
653///
654/// Not a quality bar — a *detection* bar. [`CONTROL_TEMPERATURE`] is chosen to
655/// be unambiguously bad, so a gap smaller than this means the measurement did
656/// not respond to a change that should have been impossible to miss.
657pub const CONTROL_MIN_COMPOSITE_GAP: f64 = 0.05;
658
659/// What the positive control demonstrated about this run's sensitivity.
660///
661/// Three outcomes rather than a bool, because the two ways of failing mean
662/// different things and want different fixes. Collapsing them is what made a
663/// real 0.090 swing render as "changed by only -0.090" — wording that reads as
664/// *barely moved* about a control that moved a great deal, in the wrong
665/// direction. That is [ADR 0004] decision 3's rule applied to a verdict rather
666/// than to a field: a state that licenses a different action must render
667/// differently.
668///
669/// [ADR 0004]: https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md
670#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
671#[serde(tag = "verdict", rename_all = "snake_case")]
672pub enum ControlVerdict {
673 /// The control scored at least [`CONTROL_MIN_COMPOSITE_GAP`] below the
674 /// gglib arm. The apparatus moved under a known-bad change, so a null
675 /// result elsewhere in the report is evidence rather than silence.
676 Moved {
677 /// `gglib − control`, positive.
678 gap: f64,
679 },
680 /// The control scored below the gglib arm but by less than the threshold.
681 /// The apparatus may simply be insensitive at this suite size.
682 TooSmall {
683 /// `gglib − control`, positive but under the threshold.
684 gap: f64,
685 },
686 /// **The control scored *higher* than the gglib arm.**
687 ///
688 /// Not a weak signal — a contradicted premise. The change was chosen to be
689 /// bad, so a control that wins says the degradation is not degrading, and
690 /// the control itself needs fixing before any delta in the report means
691 /// anything. Measured once already: temperature 2.0 without disabling
692 /// `top_k` is absorbed by the truncation samplers that run ahead of it.
693 WrongDirection {
694 /// How far *above* the gglib arm the control scored, positive.
695 gap: f64,
696 },
697}
698
699impl ControlVerdict {
700 /// Whether this run demonstrated it could detect a sampling change.
701 #[must_use]
702 pub const fn demonstrated_sensitivity(&self) -> bool {
703 matches!(self, Self::Moved { .. })
704 }
705}
706
707/// How many times the raw-versus-gglib effect must exceed the A/A drift before
708/// the report will call it more than noise.
709///
710/// # This is a rule of thumb, not a test
711///
712/// A single A/A pair estimates the drift from one degree of freedom. Two draws
713/// of a noisy quantity can land close together by luck, and a factor derived
714/// from them carries no confidence level, no *p*, and no power. `2.0` is chosen
715/// because it is the smallest factor at which the two numbers are plainly not
716/// the same size — enough to stop a delta that is *within* its own noise from
717/// being reported as a finding, and not enough to license the word
718/// "significant" about one that clears it.
719///
720/// The honest way to strengthen this is more A/A pairs, not a bigger factor.
721pub const EFFECT_NOISE_RATIO: f64 = 2.0;
722
723/// What the A/A arm says about the size of the measured effect.
724///
725/// Deliberately not a p-value or a confidence interval: with one replicate
726/// there is nothing to compute either from, and rendering a statistic that the
727/// design cannot support would be worse than rendering none. The two arms of
728/// this enum are the honest resolution of a one-pair comparison — the effect is
729/// clearly bigger than the drift, or it is not clearly bigger than the drift.
730#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
731#[serde(tag = "verdict", rename_all = "snake_case")]
732pub enum EffectVerdict {
733 /// `|gglib − raw|` is at least [`EFFECT_NOISE_RATIO`] times the A/A gap.
734 ExceedsNoise {
735 /// `gglib − raw`, signed: a negative effect that clears the noise floor
736 /// is still a finding, just not the hoped-for one.
737 effect: f64,
738 /// The mean pairwise drift among identical raw runs.
739 noise: f64,
740 /// Pairwise gaps behind `noise` — the estimate's degrees of freedom.
741 /// A verdict over one pair and one over six are not the same strength
742 /// of claim, and rendering them identically invites exactly the
743 /// misreading the A/A arm exists to prevent.
744 #[serde(default = "one")]
745 pairs: usize,
746 },
747 /// The effect is not clearly larger than the drift between two runs of the
748 /// same arm. It is not thereby *absent* — it is unresolved at this seed
749 /// count, and the fix is more seeds rather than a different conclusion.
750 WithinNoise {
751 /// `gglib − raw`, signed.
752 effect: f64,
753 /// The mean pairwise drift among identical raw runs.
754 noise: f64,
755 /// Pairwise gaps behind `noise`.
756 #[serde(default = "one")]
757 pairs: usize,
758 },
759}
760
761impl EffectVerdict {
762 /// `|effect| ÷ noise`, or `None` when the two arms landed on exactly the
763 /// same composite and the ratio would divide by zero.
764 ///
765 /// A zero denominator is not a licence to report an infinite ratio: two
766 /// identical scores on a suite this small means the drift went unresolved,
767 /// not that there is none.
768 #[must_use]
769 pub fn ratio(&self) -> Option<f64> {
770 let (effect, noise) = match *self {
771 Self::ExceedsNoise { effect, noise, .. } | Self::WithinNoise { effect, noise, .. } => {
772 (effect, noise)
773 }
774 };
775 (noise > 0.0).then(|| effect.abs() / noise)
776 }
777
778 /// The signed `gglib − raw` difference this verdict is about.
779 #[must_use]
780 pub const fn effect(&self) -> f64 {
781 match *self {
782 Self::ExceedsNoise { effect, .. } | Self::WithinNoise { effect, .. } => effect,
783 }
784 }
785
786 /// How many pairwise drift gaps stand behind [`Self::noise`].
787 #[must_use]
788 pub const fn pairs(&self) -> usize {
789 match *self {
790 Self::ExceedsNoise { pairs, .. } | Self::WithinNoise { pairs, .. } => pairs,
791 }
792 }
793
794 /// The A/A drift this verdict measured the effect against.
795 #[must_use]
796 pub const fn noise(&self) -> f64 {
797 match *self {
798 Self::ExceedsNoise { noise, .. } | Self::WithinNoise { noise, .. } => noise,
799 }
800 }
801
802 /// Whether the effect cleared the drift by [`EFFECT_NOISE_RATIO`].
803 #[must_use]
804 pub const fn exceeds_noise(&self) -> bool {
805 matches!(self, Self::ExceedsNoise { .. })
806 }
807}
808
809impl ArmScores {
810 /// Runs that reached the model — the denominator every comparable figure
811 /// on this arm is taken over.
812 ///
813 /// Saturating, because a legacy row can carry an `unmeasured_runs` it never
814 /// had a `runs` for, and a wrapped denominator would be far worse than a
815 /// zero one.
816 #[must_use]
817 pub const fn measured_runs(&self) -> usize {
818 self.runs.saturating_sub(self.unmeasured_runs)
819 }
820
821 /// Whether **no** run in this arm reached the model.
822 ///
823 /// The state that must never render as a score. An arm in it has a
824 /// composite, a tool accuracy and a task completion, all arithmetically
825 /// correct and all meaningless — computed over runs that produced no
826 /// response to score.
827 #[must_use]
828 pub const fn is_empty_column(&self) -> bool {
829 self.runs > 0 && self.unmeasured_runs >= self.runs
830 }
831
832 /// Whether *some* but not all of this arm's runs reached the model.
833 ///
834 /// Distinct from [`Self::is_empty_column`] because it wants a different
835 /// action: the arm has real observations mixed with empty ones, so its
836 /// means are contaminated by a knowable amount rather than vacant.
837 #[must_use]
838 pub const fn is_partly_unmeasured(&self) -> bool {
839 self.unmeasured_runs > 0 && self.unmeasured_runs < self.runs
840 }
841}
842
843impl AgenticEvalReport {
844 /// What the positive control demonstrated, or `None` when it did not run.
845 ///
846 /// `None` is distinct from any failure for the same reason `Blind` is
847 /// distinct from zero divergences: nothing was claimed either way.
848 #[must_use]
849 pub fn control_verdict(&self) -> Option<ControlVerdict> {
850 let control = self.control.as_ref()?;
851 let gap = self.gglib.composite - control.composite;
852 Some(if gap >= CONTROL_MIN_COMPOSITE_GAP {
853 ControlVerdict::Moved { gap }
854 } else if gap >= 0.0 {
855 ControlVerdict::TooSmall { gap }
856 } else {
857 ControlVerdict::WrongDirection { gap: -gap }
858 })
859 }
860
861 /// The eval's own drift: the mean pairwise composite gap over every run
862 /// of the identical raw configuration — the primary plus each A/A pair.
863 ///
864 /// With one A/A pair this is exactly the old single-gap number. With `K`
865 /// pairs it averages the `C(K+1, 2)` pairwise gaps among `K + 1` runs of
866 /// the same arm, which estimates the same quantity from more than one
867 /// degree of freedom. A mean absolute gap, not a standard deviation:
868 /// [`EFFECT_NOISE_RATIO`] was calibrated against a gap, and changing the
869 /// estimator and the threshold at once would make old and new verdicts
870 /// incomparable.
871 ///
872 /// `None` when no A/A arm ran, which is distinct from a measured zero for
873 /// the same reason `Blind` is distinct from zero divergences.
874 #[must_use]
875 pub fn noise_floor(&self) -> Option<f64> {
876 let gaps = self.drift_gaps();
877 #[allow(clippy::cast_precision_loss)]
878 match gaps.len() {
879 0 => None,
880 n => Some(gaps.iter().sum::<f64>() / n as f64),
881 }
882 }
883
884 /// How many pairwise gaps stand behind [`Self::noise_floor`] — the
885 /// degrees of freedom a reader should weigh the verdict by.
886 #[must_use]
887 pub fn noise_pairs(&self) -> usize {
888 self.drift_gaps().len()
889 }
890
891 /// Pairwise absolute composite gaps among every run of the raw
892 /// configuration. Empty when no A/A arm ran.
893 fn drift_gaps(&self) -> Vec<f64> {
894 let mut composites = vec![self.raw.composite];
895 if self.raw_replicates.is_empty() {
896 if let Some(replicate) = self.raw_replicate.as_ref() {
897 composites.push(replicate.composite);
898 }
899 } else {
900 composites.extend(self.raw_replicates.iter().map(|r| r.composite));
901 }
902 let mut gaps = Vec::new();
903 for (i, a) in composites.iter().enumerate() {
904 for b in &composites[i + 1..] {
905 gaps.push((a - b).abs());
906 }
907 }
908 gaps
909 }
910
911 /// Whether the measured effect is larger than the eval's own drift.
912 ///
913 /// `None` when no A/A arm ran, or when the composite delta was withheld —
914 /// in either case the report contains no basis for the judgement. The
915 /// second guard matters as much as the first: comparing a *diluted* effect
916 /// against a drift figure produces a confident ratio out of two numbers
917 /// that are not about the same thing, which is how a contaminated −0.058
918 /// came to be reported as "8.3× the drift".
919 #[must_use]
920 pub fn effect_verdict(&self) -> Option<EffectVerdict> {
921 let noise = self.noise_floor()?;
922 let effect = self.delta.composite?;
923 // A zero effect never "exceeds" anything, however quiet the arm was:
924 // with both terms at zero the inequality would hold vacuously and
925 // report no difference as a finding.
926 let pairs = self.noise_pairs();
927 Some(
928 if effect.abs() > 0.0 && effect.abs() >= EFFECT_NOISE_RATIO * noise {
929 EffectVerdict::ExceedsNoise {
930 effect,
931 noise,
932 pairs,
933 }
934 } else {
935 EffectVerdict::WithinNoise {
936 effect,
937 noise,
938 pairs,
939 }
940 },
941 )
942 }
943
944 /// Tasks whose outcome was not stable across seeds under either arm.
945 ///
946 /// The direct read of run-to-run variance, and the first thing to look at
947 /// when two arms differ by less than they ought to.
948 #[must_use]
949 pub fn unstable_tasks(&self) -> Vec<&AgenticTaskComparison> {
950 self.tasks.iter().filter(|t| t.is_unstable()).collect()
951 }
952
953 /// The paired per-`(task, seed)` comparison, derived from the drill-down.
954 ///
955 /// Derived rather than stored, like the verdicts above it — which also
956 /// means a legacy report's stored per-seed detail yields it retroactively.
957 /// `None` when no pair has both sides measured.
958 #[must_use]
959 pub fn paired_effect(&self) -> Option<PairedEffect> {
960 self.paired
961 .or_else(|| PairedEffect::from_tasks(&self.tasks))
962 }
963
964 /// Compute the per-axis delta from the two arms' scores.
965 ///
966 /// `weights` is needed because the compared composite is **not** the
967 /// difference of the two stored composites: each of those is renormalized
968 /// over the axes its own arm measured, so subtracting them across a
969 /// mismatch measures the renormalization. The axes both arms share are
970 /// re-weighted here instead.
971 #[must_use]
972 pub fn delta_of(raw: &ArmScores, gglib: &ArmScores, weights: &ScoreWeights) -> ArmDelta {
973 let withheld = DeltaWithheld::for_arms(raw, gglib);
974 let comparable = withheld.is_none();
975
976 // An axis measured on one side only cannot take part. Dropping it from
977 // *both* is the symmetric choice; keeping it for the arm that has it
978 // hands that arm a free score on an axis its opponent was never scored
979 // against.
980 let shared_loop = raw.loop_avoidance.is_some() && gglib.loop_avoidance.is_some();
981 let composite_of = |arm: &ArmScores| {
982 weights.composite_of(
983 arm.tool_accuracy,
984 shared_loop.then_some(arm.loop_avoidance).flatten(),
985 arm.task_completion,
986 )
987 };
988
989 ArmDelta {
990 tool_accuracy: comparable.then_some(gglib.tool_accuracy - raw.tool_accuracy),
991 loop_avoidance: comparable
992 .then(|| {
993 gglib
994 .loop_avoidance
995 .zip(raw.loop_avoidance)
996 .map(|(g, r)| g - r)
997 })
998 .flatten(),
999 task_completion: comparable.then_some(gglib.task_completion - raw.task_completion),
1000 composite: comparable.then(|| composite_of(gglib) - composite_of(raw)),
1001 wall_time_speedup: ratio(
1002 per_run(Some(as_f64(raw.measured_wall_ms)), raw.measured_runs()),
1003 per_run(Some(as_f64(gglib.measured_wall_ms)), gglib.measured_runs()),
1004 ),
1005 completion_token_ratio: ratio(
1006 per_run(raw.total_completion_tokens.map(as_f64), raw.measured_runs()),
1007 per_run(
1008 gglib.total_completion_tokens.map(as_f64),
1009 gglib.measured_runs(),
1010 ),
1011 ),
1012 withheld,
1013 }
1014 }
1015}
1016
1017impl DeltaWithheld {
1018 /// Whether these two arms can be compared at the arm level at all.
1019 #[must_use]
1020 pub const fn for_arms(raw: &ArmScores, gglib: &ArmScores) -> Option<Self> {
1021 if raw.unmeasured_runs == 0 && gglib.unmeasured_runs == 0 {
1022 return None;
1023 }
1024 Some(Self::ContaminatedByUnmeasuredRuns {
1025 raw: raw.unmeasured_runs,
1026 gglib: gglib.unmeasured_runs,
1027 })
1028 }
1029}
1030
1031/// A total spread over the runs that produced it. `None` when there were none,
1032/// which keeps "no measured runs" distinct from "a measured zero".
1033fn per_run(total: Option<f64>, runs: usize) -> Option<f64> {
1034 match (total, runs) {
1035 (Some(total), runs) if runs > 0 =>
1036 {
1037 #[allow(clippy::cast_precision_loss)]
1038 Some(total / runs as f64)
1039 }
1040 _ => None,
1041 }
1042}
1043
1044/// The paired view of the raw-versus-gglib comparison.
1045///
1046/// The two real arms run the **same seeds on the same tasks**, so every
1047/// `(task, seed)` cell is a matched pair — and pairing is what removes the
1048/// eval's identical-arm spread from the comparison. The ceiling experiment
1049/// (tune runs #12–#32, ADR 0004's postscript) resolved a +0.067 effect
1050/// through noise wider than that *only* because it paired per run; the same
1051/// data has been sitting in [`AgenticEvalReport::tasks`] all along, compared
1052/// only as arm means.
1053///
1054/// Pairs are on [`TuneTaskResult::tool_match_score`] — the one graded
1055/// per-run quality scalar. Pass/fail flips remain visible per task in
1056/// [`AgenticTaskComparison::pass_counts`]; folding them in here would double
1057/// count, since the match score is most of what decides `passed`.
1058#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1059#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
1060pub struct PairedEffect {
1061 /// Matched `(task, seed)` pairs in which both arms produced a real
1062 /// observation.
1063 pub pairs: usize,
1064 /// Pairs both arms ran but at least one side never reached the model —
1065 /// dropped from every number here, and reported so the drop is visible.
1066 pub unmeasured_pairs: usize,
1067 /// Pairs the gglib arm scored strictly higher.
1068 pub wins: usize,
1069 /// Pairs the raw arm scored strictly higher.
1070 pub losses: usize,
1071 /// Pairs with identical scores. On a suite where most tasks pass cleanly
1072 /// under both arms this is the largest bucket, and that is information:
1073 /// the arms mostly agree.
1074 pub ties: usize,
1075 /// Mean of `gglib − raw` over the measured pairs.
1076 pub mean_delta: f64,
1077 /// One-sided Wilcoxon signed-rank *p* for "gglib scores higher", by
1078 /// normal approximation with tie correction.
1079 ///
1080 /// `None` below [`WILCOXON_MIN_PAIRS`] non-tied pairs — the approximation
1081 /// is not trustworthy there, and rendering a statistic the design cannot
1082 /// support is worse than rendering none (the [`EffectVerdict`] rule). At
1083 /// small counts, read [`Self::wins`] against [`Self::losses`] instead.
1084 pub p_value: Option<f64>,
1085}
1086
1087/// The fewest non-tied pairs the normal-approximation Wilcoxon accepts.
1088///
1089/// Below this the approximation's error is material and an exact table would
1090/// be needed; above it the correction terms keep it honest.
1091pub const WILCOXON_MIN_PAIRS: usize = 8;
1092
1093impl PairedEffect {
1094 /// Compute the paired comparison from the per-task drill-down.
1095 ///
1096 /// `None` when no `(task, seed)` pair has both sides measured — a paired
1097 /// analysis of nothing is not a zero effect.
1098 #[must_use]
1099 pub fn from_tasks(tasks: &[AgenticTaskComparison]) -> Option<Self> {
1100 let mut deltas = Vec::new();
1101 let mut unmeasured_pairs = 0_usize;
1102 for task in tasks {
1103 for (raw, gglib) in task.raw.iter().zip(task.gglib.iter()) {
1104 if raw.is_measured() && gglib.is_measured() {
1105 deltas.push(gglib.tool_match_score - raw.tool_match_score);
1106 } else {
1107 unmeasured_pairs += 1;
1108 }
1109 }
1110 }
1111 Self::from_deltas(&deltas, unmeasured_pairs)
1112 }
1113
1114 /// The paired comparison between two runs of the same task list, paired
1115 /// by `task_id` — the first argument's score minus the second's, so
1116 /// `wins` counts pairs the *first* run took.
1117 ///
1118 /// Built for the tune apply gate (winner versus incumbent), where the
1119 /// two sides are candidates rather than eval arms. A task present in one
1120 /// run and absent from the other is skipped, not counted: an unpaired
1121 /// task has nothing to compare.
1122 #[must_use]
1123 pub fn from_paired_runs(a: &[TuneTaskResult], b: &[TuneTaskResult]) -> Option<Self> {
1124 let b_by_id: std::collections::HashMap<&str, &TuneTaskResult> =
1125 b.iter().map(|r| (r.task_id.as_str(), r)).collect();
1126 let mut deltas = Vec::new();
1127 let mut unmeasured_pairs = 0_usize;
1128 for left in a {
1129 let Some(right) = b_by_id.get(left.task_id.as_str()) else {
1130 continue;
1131 };
1132 if left.is_measured() && right.is_measured() {
1133 deltas.push(left.tool_match_score - right.tool_match_score);
1134 } else {
1135 unmeasured_pairs += 1;
1136 }
1137 }
1138 Self::from_deltas(&deltas, unmeasured_pairs)
1139 }
1140
1141 /// Aggregate a delta list into the paired record. `None` on no deltas —
1142 /// a paired analysis of nothing is not a zero effect.
1143 fn from_deltas(deltas: &[f64], unmeasured_pairs: usize) -> Option<Self> {
1144 if deltas.is_empty() {
1145 return None;
1146 }
1147
1148 let wins = deltas.iter().filter(|d| **d > 0.0).count();
1149 let losses = deltas.iter().filter(|d| **d < 0.0).count();
1150 let ties = deltas.len() - wins - losses;
1151 #[allow(clippy::cast_precision_loss)]
1152 let mean_delta = deltas.iter().sum::<f64>() / deltas.len() as f64;
1153
1154 Some(Self {
1155 pairs: deltas.len(),
1156 unmeasured_pairs,
1157 wins,
1158 losses,
1159 ties,
1160 mean_delta,
1161 p_value: wilcoxon_one_sided(deltas),
1162 })
1163 }
1164}
1165
1166/// One-sided Wilcoxon signed-rank *p* for "the deltas are positive".
1167///
1168/// Textbook construction: zeros dropped, absolute deltas ranked with average
1169/// ranks over ties, `W⁻` (the rank sum of the negative deltas) compared
1170/// against its null distribution by normal approximation with the tie
1171/// correction and a continuity correction. Small `W⁻` — losses carrying
1172/// little rank weight — yields small *p*.
1173///
1174/// `None` when fewer than [`WILCOXON_MIN_PAIRS`] non-zero deltas remain.
1175fn wilcoxon_one_sided(deltas: &[f64]) -> Option<f64> {
1176 let mut nonzero: Vec<f64> = deltas.iter().copied().filter(|d| *d != 0.0).collect();
1177 let n = nonzero.len();
1178 if n < WILCOXON_MIN_PAIRS {
1179 return None;
1180 }
1181 nonzero.sort_by(|a, b| a.abs().partial_cmp(&b.abs()).expect("scores are finite"));
1182
1183 // Average ranks over runs of tied |delta|, accumulating the tie
1184 // correction term as each run closes.
1185 let mut w_minus = 0.0_f64;
1186 let mut tie_correction = 0.0_f64;
1187 let mut index = 0;
1188 while index < n {
1189 let mut end = index + 1;
1190 // Bitwise equality is the right tie test here: ranks tie when the
1191 // stored |delta| values are literally the same number, and a margin
1192 // would invent ties between distinct scores.
1193 while end < n && (nonzero[end].abs() - nonzero[index].abs()).abs() == 0.0 {
1194 end += 1;
1195 }
1196 #[allow(clippy::cast_precision_loss)]
1197 let average_rank = ((index + 1 + end) as f64) / 2.0;
1198 let run = end - index;
1199 if run > 1 {
1200 #[allow(clippy::cast_precision_loss)]
1201 let t = run as f64;
1202 tie_correction += (t * t).mul_add(t, -t);
1203 }
1204 for value in &nonzero[index..end] {
1205 if *value < 0.0 {
1206 w_minus += average_rank;
1207 }
1208 }
1209 index = end;
1210 }
1211
1212 #[allow(clippy::cast_precision_loss)]
1213 let nf = n as f64;
1214 let mean = nf * (nf + 1.0) / 4.0;
1215 let variance = nf * (nf + 1.0) * 2.0f64.mul_add(nf, 1.0) / 24.0 - tie_correction / 48.0;
1216 if variance <= 0.0 {
1217 // Every |delta| identical and tied: the statistic is degenerate, and
1218 // the sign test the caller can read from wins/losses is the honest
1219 // fallback.
1220 return None;
1221 }
1222 // Continuity correction toward the mean; "gglib higher" means W⁻ is
1223 // small, so the one-sided p is the lower tail.
1224 let z = (w_minus - mean + 0.5) / variance.sqrt();
1225 Some(normal_cdf(z))
1226}
1227
1228/// Standard normal CDF via Abramowitz–Stegun 7.1.26 on `erf`, accurate to
1229/// ~1.5e-7 — orders of magnitude finer than any decision read from a *p*.
1230fn normal_cdf(z: f64) -> f64 {
1231 let x = z / std::f64::consts::SQRT_2;
1232 let t = 1.0 / 0.327_591_1f64.mul_add(x.abs(), 1.0);
1233 let poly = t
1234 * (0.254_829_592
1235 + t * (-0.284_496_736
1236 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
1237 let erf = 1.0 - poly * (-x * x).exp();
1238 let signed = if x < 0.0 { -erf } else { erf };
1239 0.5 * (1.0 + signed)
1240}
1241
1242/// `raw ÷ gglib`, or `None` when either side is unmeasured or the denominator
1243/// is zero — an infinite speedup is not a measurement.
1244fn ratio(raw: Option<f64>, gglib: Option<f64>) -> Option<f64> {
1245 match (raw, gglib) {
1246 (Some(r), Some(g)) if g > 0.0 => Some(r / g),
1247 _ => None,
1248 }
1249}
1250
1251/// Widen a count for ratio arithmetic. Suite totals are far below the 2^53
1252/// boundary where `f64` stops representing integers exactly.
1253#[allow(clippy::cast_precision_loss)]
1254const fn as_f64(value: u64) -> f64 {
1255 value as f64
1256}
1257
1258#[cfg(test)]
1259#[path = "agentic_tests.rs"]
1260mod agentic_tests;