Skip to main content

gglib_core/domain/benchmark/tune/
result.rs

1//! Per-candidate and per-task results produced by a tune run.
2
3use serde::{Deserialize, Serialize};
4
5use crate::domain::inference::InferenceConfig;
6
7use super::task::TaskCategory;
8
9/// Where a tune candidate's sampling settings came from.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "kind", rename_all = "snake_case")]
12#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
13pub enum CandidateSource {
14    /// One point in the user-specified [`super::config::SweepSpec`] grid.
15    UserGrid,
16    /// Seeded from the built-in per-model-family preset table (e.g. Qwen
17    /// coding-mode defaults).
18    FamilyPreset {
19        /// Display name of the matched family/preset (e.g. `"qwen-coding"`).
20        family: String,
21    },
22    /// The model's current behaviour: an all-`None` overlay, which resolves
23    /// through the normal chain and is therefore exactly what an untouched
24    /// request gets today. Always included, never pruned — a winner that
25    /// never raced the incumbent has not beaten it.
26    Incumbent,
27    /// The incumbent again, identically. The gap between the twins is the
28    /// run's own drift — the in-run calibration the apply gate divides every
29    /// margin by (see `tune::apply`). Excluded from the leaderboard's notion
30    /// of "winner": it is an instrument, not a contender.
31    IncumbentCalibration,
32}
33
34/// The *shape* of what a run generated, as opposed to how much.
35///
36/// # Why this exists
37///
38/// Until this struct, the eval counted output and threw it away: 7 of the 9
39/// `AgentEvent` variants — `TextDelta` and `ReasoningDelta` among them — fell
40/// through the benchmark's event loop untouched. A run was therefore knowable
41/// only as a token total and a wall time.
42///
43/// That is not enough to read a run. On 2026-08-29 five runs generated ~32,900
44/// completion tokens apiece against ~510 for the same task without the
45/// pipeline, took ~950s, and **passed**. Nothing recorded anywhere could say
46/// whether that was a small reasoning model thinking at length or a generation
47/// fault, and the two call for opposite responses. This struct is the
48/// difference between those two readings.
49///
50/// Every field is taken from events the loop already emitted, so nothing here
51/// changes what the eval sends, executes or scores.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
54pub struct GeneratedOutput {
55    /// Characters the model emitted as **reasoning** (chain-of-thought).
56    ///
57    /// # This is only meaningful when the upstream separates reasoning
58    ///
59    /// It counts `AgentEvent::ReasoningDelta`, which exists only when
60    /// llama-server was launched with `--reasoning-format deepseek` and so
61    /// splits thinking into its own `reasoning_content` SSE field. Without that
62    /// flag a reasoning model's thinking arrives inline as `<think>…</think>`,
63    /// the normalizer strips the tags, and every one of those characters is
64    /// counted as [`Self::answer_chars`] instead.
65    ///
66    /// So `reasoning_chars: 0` beside a large `answer_chars` is **ambiguous**:
67    /// it means either the model did not think, or it thought and nobody could
68    /// tell. Resolve it by checking whether the model carries the `reasoning`
69    /// capability tag, not by assuming.
70    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
71    pub reasoning_chars: u64,
72    /// Characters the model emitted as ordinary answer text, summed across
73    /// every turn — not just the final one.
74    ///
75    /// Counted from `AgentEvent::TextDelta` rather than from `FinalAnswer`,
76    /// which carries the same text already accumulated and would double it.
77    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
78    pub answer_chars: u64,
79    /// How many requests the run actually sent to the model.
80    ///
81    /// Distinct from [`TuneTaskResult::iterations`], which counts only
82    /// *tool-executing* turns — a run that ends by answering in text made one
83    /// more request than it reports iterations. Dividing tokens by `iterations`
84    /// therefore overstates per-request generation, by 50% on a two-iteration
85    /// run, which is exactly the arithmetic a reader performs when asking
86    /// whether a token cap was in force.
87    ///
88    /// Derived from the event stream (one per `IterationComplete`, plus one for
89    /// a `FinalAnswer`), so a run a guard aborted mid-turn under-counts by the
90    /// aborting request. Read it as a floor on those runs.
91    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
92    pub llm_calls: usize,
93    /// The largest single batch of tool calls any one turn executed.
94    ///
95    /// The fingerprint of a constrained-decoding runaway. gglib's generated
96    /// grammar admits `root ::= sp call (sp call)* sp` — unbounded repetition —
97    /// so a model that never emits an end-of-generation token can keep producing
98    /// syntactically valid calls until it hits a token cap or the context limit.
99    /// Scoring cannot reveal this: extra unrequested calls cost nothing, so a
100    /// batch of hundreds containing the right call still scores `1.0` and the
101    /// task still reads as passed.
102    ///
103    /// # It saturates at 64
104    ///
105    /// The collector drops tool-call fragments past `MAX_TOOL_CALL_INDEX` (64),
106    /// so this is a *floor*, and a reading of exactly 64 means "at least 64" —
107    /// the true batch went into `CollectedResponse::tool_calls_truncated` and
108    /// from there into a `SystemWarning` message this eval keeps no text of.
109    /// Measured 2026-08-29: a reading of 64 was `kept=64 dropped=542` — **606
110    /// calls in one response**, for a task whose expected output is one call.
111    /// An earlier run logged `dropped=1237`. Read a 64 as "consult the daemon
112    /// log", never as a batch size.
113    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
114    pub max_tool_calls_in_batch: usize,
115    /// How many recoverable conditions the loop reported during this run.
116    ///
117    /// Counts `AgentEvent::SystemWarning`, whose main source is the loop
118    /// recovering from a model that requested more parallel tool calls than the
119    /// configured limit. That recovery costs a whole extra request and was
120    /// previously invisible to the eval: the warning was emitted, discarded, and
121    /// the run reported as though nothing had happened.
122    ///
123    /// **Warnings, not incidents.** One over-wide batch raises two — the
124    /// collector's slot limit and then the parallel-tool limit — so this
125    /// over-states how many times the model ran away, by up to a factor of two.
126    /// It is a "something went wrong here" flag; the log holds the account.
127    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
128    pub system_warnings: u32,
129}
130
131/// Result of evaluating one task against one candidate's sampling settings.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
134pub struct TuneTaskResult {
135    /// ID of the [`super::task::TuneTask`] this result corresponds to.
136    pub task_id: String,
137    /// Category the task belongs to (carried for leaderboard grouping).
138    pub category: TaskCategory,
139    /// `true` if the agent loop completed and its tool calls matched the
140    /// task's expected outcome (for `NoToolCall` tasks: no call was made).
141    pub passed: bool,
142    /// AST-style match score against the expected outcome, `0.0`–`1.0`.
143    ///
144    /// Partial credit: e.g. right tool name but a missing required
145    /// argument scores between `0.0` and `1.0`, not a hard fail.
146    pub tool_match_score: f64,
147    /// `true` if the agent loop's `LoopDetector` fired during this task.
148    pub loop_detected: bool,
149    /// `true` if the agent loop's `StagnationDetector` fired during this task.
150    pub stagnation_detected: bool,
151    /// Number of *tool-executing* agent-loop iterations that completed.
152    ///
153    /// The loop reports an iteration only after it has executed that turn's
154    /// tool calls, so a turn that answered in text — including the final one —
155    /// is not counted, and a guard-aborted run reports one fewer than the turn
156    /// it aborted on. Read it as "how many tool-call batches this run
157    /// produced", which is what decides whether a repeat was even possible.
158    pub iterations: usize,
159    /// Wall-clock time spent on this task, in milliseconds.
160    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
161    pub latency_ms: u64,
162    /// Completion tokens generated across the task's agent run, summed from
163    /// the upstream's per-response usage reports.
164    ///
165    /// Counted independently of how the run ended, so a run a guard aborted
166    /// still reports the tokens it burned — those are the runs whose cost
167    /// matters most. `None` only when the upstream reported no usage at all,
168    /// which stays distinct from a measured zero.
169    #[serde(default)]
170    #[cfg_attr(feature = "ts-bindings", ts(type = "number | null"))]
171    pub completion_tokens: Option<u64>,
172    /// Wall-clock milliseconds from the start of the task to the first tool
173    /// call the model actually issued — how long it took to take its first
174    /// useful action.
175    ///
176    /// This is the figure an agentic client's user feels: a turn that emits a
177    /// valid call in 300 ms and one that emits the same call after 140 s of
178    /// unconstrained generation score identically on every accuracy axis.
179    /// `None` when the task never called a tool, which is the correct outcome
180    /// for an `Irrelevance` task.
181    #[serde(default)]
182    #[cfg_attr(feature = "ts-bindings", ts(type = "number | null"))]
183    pub time_to_first_tool_call_ms: Option<u64>,
184    /// Optional human-readable detail (e.g. which expected call was missed),
185    /// surfaced in the leaderboard drill-down.
186    #[serde(default)]
187    pub detail: Option<String>,
188    /// Why this run is **not a measurement of the model**, when it is not one.
189    ///
190    /// `None` on every run that actually reached the model, including every
191    /// way of doing badly: a wrong tool call, a detected loop, a stagnated
192    /// answer and an exhausted iteration budget are all real observations and
193    /// score honestly as failures.
194    ///
195    /// `Some(reason)` is the different thing — the request never produced a
196    /// response to score, because the upstream was unreachable, the stream
197    /// broke, or the loop could not start. Such a run still carries
198    /// `passed: false` and `tool_match_score: 0.0`, and **those zeros mean
199    /// nothing**: they are the absence of a measurement wearing the costume of
200    /// a bad one.
201    ///
202    /// Measured, which is why this field exists. A run whose llama-server had
203    /// died scored a composite of `0.222` across 45 failed requests and
204    /// rendered as an ordinary, believable arm — a −0.562 delta that read as a
205    /// catastrophic regression rather than as an empty column. An arm that
206    /// cannot tell "the model did badly" from "there was no model" is
207    /// reporting a number it never took.
208    #[serde(default)]
209    pub unmeasured: Option<String>,
210    /// How many attempts this run threw away to a transport failure before the
211    /// one reported here.
212    ///
213    /// `0` on a run that succeeded first time. Non-zero means the harness hit
214    /// [`Self::unmeasured`] and tried again, so the numbers above come from a
215    /// later attempt than the one the suite nominally ran.
216    ///
217    /// Recorded rather than swallowed because a silently-retried run is not the
218    /// same measurement as a clean one, and an eval that hides its retries can
219    /// report a healthy suite while the upstream underneath it is failing one
220    /// request in ten. It is also the reading its own kill criterion needs: if
221    /// this stays `0` across two full evals, the retry is unnecessary and goes.
222    #[serde(default)]
223    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
224    pub transport_retries: u32,
225    /// What the model generated, as opposed to how much of it.
226    ///
227    /// See [`GeneratedOutput`] — a token total and a wall time cannot
228    /// distinguish a model thinking at length from one failing to stop, and
229    /// those call for opposite responses.
230    #[serde(default)]
231    pub generated: GeneratedOutput,
232}
233
234impl TuneTaskResult {
235    /// Whether this run produced a real observation of the model.
236    ///
237    /// Read this rather than `passed`, wherever the question is "is this
238    /// number worth anything" rather than "did the model succeed".
239    #[must_use]
240    pub const fn is_measured(&self) -> bool {
241        self.unmeasured.is_none()
242    }
243}
244
245/// Result of evaluating one candidate's sampling settings against the full
246/// (or pre-screen) task suite.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
249pub struct TuneCandidateResult {
250    /// The candidate's resolved sampling settings.
251    pub config: InferenceConfig,
252    /// Where this candidate's settings came from.
253    pub source: CandidateSource,
254    /// Per-task results for this candidate.
255    pub task_results: Vec<TuneTaskResult>,
256    /// Weighted composite score (see [`super::config::ScoreWeights`]).
257    pub composite_score: f64,
258    /// `true` if this candidate was dropped after the pre-screen round and
259    /// never ran the full suite (`task_results` only covers the pre-screen
260    /// tasks in that case).
261    pub pruned: bool,
262    /// Completion-token throughput observed for this candidate, in tokens
263    /// per wall-clock second across its evaluated tasks (total completion
264    /// tokens ÷ total task wall time, which includes prompt pre-fill — a
265    /// consistent within-run comparison figure, not a pure decode rate).
266    /// `None` when no task reported usage.
267    #[serde(default)]
268    pub tg_tps: Option<f64>,
269}
270
271#[cfg(test)]
272#[path = "result_tests.rs"]
273mod result_tests;