Skip to main content

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;