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;