Skip to main content

mira/
protocol.rs

1//! The Mira eval protocol: newline-delimited JSON over stdio, MCP-style.
2//!
3//! Two processes talk:
4//! * the **study** (your eval program) — defines evals in Rust, owns subject
5//!   construction and scoring, and knows nothing about selection, the matrix,
6//!   aggregation, checkpoints, or rendering. See [`crate::study`].
7//! * the **host** (the `mira` CLI) — compiles + spawns the study, enumerates
8//!   evals, plans the run (selection × matrix), drives execution, then
9//!   aggregates / saves / checkpoints / visualizes. See [`crate::host`].
10//!
11//! Provider API keys live only in the study's environment and never cross the
12//! wire — the host addresses targets by *label*.
13//!
14//! ## Framing
15//! One JSON object per line, classified by **fields** (not by which pipe it
16//! arrived on): a line bearing `method` is a [`Request`] (with `id`) or a
17//! [`Notification`] (no `id`); a line without `method` is a [`Response`], routed
18//! by `id`. Today [`Request`]s flow host→study and [`Response`]/[`Notification`]
19//! flow study→host. Classifying on `method` rather than direction is deliberate:
20//! it keeps a future **reverse** request (study→host) an additive, minor change
21//! rather than a breaking one (see the reverse-channel seam in
22//! `docs/protocol.md`).
23//!
24//! ## Methods
25//! * `initialize` → [`InitializeResult`]
26//! * `list` → [`ListResult`] — the eval catalogue, with the **first page** of
27//!   each eval's samples inline
28//! * `list_samples` ([`ListSamplesParams`]) → [`ListSamplesResult`] — the next
29//!   page of one eval's samples, for datasets too large (or too lazy) to
30//!   enumerate in one `list` (advertised by the `paginate` capability)
31//! * `run` ([`RunParams`]) → [`RunResult`] — execute + score in one call
32//! * `execute` ([`RunParams`]) → [`ExecuteResult`] — execute the subject only,
33//!   returning the **full** transcript (for run-now, score-later)
34//! * `score` ([`ScoreParams`]) → [`RunResult`] — score a supplied transcript
35//!   (for deferred scoring and re-scoring)
36//! * `cancel` ([`CancelParams`]) → [`CancelResult`] — abort one in-flight `run`
37//!   /`execute`/`score` by its request `id` (for per-case timeouts, cost caps,
38//!   fail-fast)
39//!
40//! See `docs/protocol.md` for the full reference.
41
42use serde::{Deserialize, Serialize};
43
44use crate::{Metadata, Params, Score, Timing, Transcript, Usage};
45
46/// The protocol version advertised by `initialize`, as `MAJOR.MINOR`.
47///
48/// **Compatibility contract** (so old and new peers interoperate):
49/// * The **major** version changes only on a breaking wire change. Peers with
50///   different majors are incompatible — [`version_compatible`] returns false
51///   and the host warns.
52/// * The **minor** version increments for backwards-compatible additions (new
53///   methods, new optional fields). A newer peer talking to an older one must
54///   tolerate missing additions; an older peer must ignore unknown fields.
55///
56/// Every payload struct here is *non-exhaustive on the wire*: unknown fields are
57/// ignored (no `deny_unknown_fields`) and new fields are `#[serde(default)]`, so
58/// adding a field is a minor, non-breaking change.
59///
60/// The `1.0` baseline carries the full method set (`initialize`, `list`,
61/// `list_samples`, `run`, `execute`, `score`, `cancel`), typed and
62/// `request_id`-correlated `event`/`log` notifications
63/// ([`EventParams`]/[`LogParams`]), JSON-RPC-shaped [`RpcError`]s, trials/seed
64/// repetitions (pass@k / variance), cursor-paginated sample listing,
65/// eval/sample/target `metadata` (open-ended JSON), and multimodal `output` plus
66/// structured `capability_params`.
67///
68/// **`1.1`** (current) adds the structured **ATIF trajectory**: one optional
69/// field (`Transcript::trajectory`, riding `execute` results and `score`
70/// params) plus the [`capabilities::TRAJECTORY`] token — the primary structured
71/// trajectory contract (see [`crate::trajectory`]). Additive per the contract
72/// above: a `1.0` peer ignores the unknown field and token and keeps
73/// interoperating. A later addition bumps the **minor** and must stay additive
74/// (a new optional field, method, or capability token); a breaking wire change
75/// bumps the **major**.
76pub const PROTOCOL_VERSION: &str = "1.1";
77
78/// The oldest protocol version this build can still talk to.
79pub const MIN_PROTOCOL_VERSION: &str = "1.0";
80
81/// The major component of a `MAJOR.MINOR` version string (0 if malformed).
82pub fn version_major(v: &str) -> u32 {
83    v.split('.')
84        .next()
85        .and_then(|s| s.parse().ok())
86        .unwrap_or(0)
87}
88
89/// Whether this build can talk to a peer advertising version `other`. Same major
90/// ⇒ compatible (minor differences are additive by contract).
91pub fn version_compatible(other: &str) -> bool {
92    version_major(other) == version_major(PROTOCOL_VERSION)
93}
94
95/// host → study.
96#[derive(Clone, Debug, Serialize, Deserialize)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98pub struct Request {
99    pub id: u64,
100    pub method: String,
101    #[serde(default)]
102    pub params: serde_json::Value,
103}
104
105/// study → host, correlated to a [`Request`] by `id`.
106#[derive(Clone, Debug, Serialize, Deserialize)]
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108pub struct Response {
109    pub id: u64,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub result: Option<serde_json::Value>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub error: Option<RpcError>,
114}
115
116impl Response {
117    pub fn ok(id: u64, result: serde_json::Value) -> Self {
118        Self {
119            id,
120            result: Some(result),
121            error: None,
122        }
123    }
124    /// A non-retryable error response carrying a plain message (code
125    /// [`codes::INTERNAL_ERROR`]). Use [`Response::err_with`] to attach a
126    /// specific code, the `retryable` hint, or structured `data`.
127    pub fn err(id: u64, message: impl Into<String>) -> Self {
128        Self::err_with(id, RpcError::new(message))
129    }
130
131    /// An error response carrying a fully-formed [`RpcError`].
132    pub fn err_with(id: u64, error: RpcError) -> Self {
133        Self {
134            id,
135            result: None,
136            error: Some(error),
137        }
138    }
139}
140
141/// A structured, JSON-RPC-shaped protocol-level error.
142///
143/// Distinct from a transcript's `error`/`error_kind`, which classify a *subject*
144/// failure (the target under test got it wrong). An [`RpcError`] is the failure of
145/// the RPC itself — bad params, an unknown method, a study-side crash, a provider
146/// outage surfaced at the transport. `code` and `retryable` let the host classify
147/// and retry the request **without parsing the human `message`**, and `data`
148/// carries optional structured context.
149///
150/// All fields beyond `message` are optional and defaulted, so a peer that sends
151/// bare `{ "message": "…" }` still parses (forward/backward compat).
152#[derive(Clone, Debug, Serialize, Deserialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154pub struct RpcError {
155    /// Numeric class of the failure (JSON-RPC convention; see [`codes`]). `0`
156    /// when unclassified. Defaulted so older peers that omit it still parse.
157    #[serde(default)]
158    pub code: i32,
159    /// Human-readable description. The only required field.
160    pub message: String,
161    /// Hint that retrying the identical request may succeed — a *transient
162    /// infrastructure* fault (provider outage, timeout, rate limit), not the
163    /// caller's mistake. Defaulted `false` so older peers parse and unknown
164    /// failures aren't retried blindly.
165    #[serde(default)]
166    pub retryable: bool,
167    /// Optional structured payload for programmatic handling (JSON-RPC `data`).
168    /// Omitted from the wire when absent.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub data: Option<serde_json::Value>,
171}
172
173/// JSON-RPC error codes used by [`RpcError::code`]. The negative range mirrors
174/// the JSON-RPC 2.0 reserved codes; `0` means unclassified.
175pub mod codes {
176    /// Invalid method parameters (e.g. a malformed `RunParams`, an unknown
177    /// eval/sample/target). The caller's mistake — not retryable.
178    pub const INVALID_PARAMS: i32 = -32602;
179    /// Method not found / unsupported.
180    pub const METHOD_NOT_FOUND: i32 = -32601;
181    /// Internal study-side error. The default for [`super::RpcError::new`].
182    pub const INTERNAL_ERROR: i32 = -32603;
183}
184
185impl RpcError {
186    /// A non-retryable internal error ([`codes::INTERNAL_ERROR`]).
187    pub fn new(message: impl Into<String>) -> Self {
188        Self {
189            code: codes::INTERNAL_ERROR,
190            message: message.into(),
191            retryable: false,
192            data: None,
193        }
194    }
195
196    /// Set the error [`code`](RpcError::code).
197    pub fn with_code(mut self, code: i32) -> Self {
198        self.code = code;
199        self
200    }
201
202    /// Mark this error as [`retryable`](RpcError::retryable) — a transient infra
203    /// fault the host may re-attempt.
204    pub fn retryable(mut self) -> Self {
205        self.retryable = true;
206        self
207    }
208
209    /// Attach a structured [`data`](RpcError::data) payload.
210    pub fn with_data(mut self, data: serde_json::Value) -> Self {
211        self.data = Some(data);
212        self
213    }
214}
215
216impl std::fmt::Display for RpcError {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        write!(f, "{}", self.message)
219    }
220}
221
222impl std::error::Error for RpcError {}
223
224/// study → host, fire-and-forget progress (no `id`). Carries live events (a
225/// turn started, a tool was called, tokens spent) so the host can render
226/// progress and, later, stream into a transcript viewer.
227#[derive(Clone, Debug, Serialize, Deserialize)]
228#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
229pub struct Notification {
230    pub method: String,
231    #[serde(default)]
232    pub params: serde_json::Value,
233}
234
235impl Notification {
236    /// The `method` of a progress `event` notification.
237    pub const EVENT: &'static str = "event";
238    /// The `method` of a free-form `log` notification.
239    pub const LOG: &'static str = "log";
240
241    /// Build a typed `event` progress notification.
242    pub fn event(params: EventParams) -> Self {
243        Self {
244            method: Self::EVENT.into(),
245            // Infallible for a plain struct of scalars/strings; `expect` keeps a
246            // serialization bug loud rather than silently emitting `params: null`.
247            params: serde_json::to_value(params).expect("EventParams serializes"),
248        }
249    }
250
251    /// Build a `log` notification. `request_id` is the request being serviced
252    /// (0 for connection-level logs, e.g. a malformed request line).
253    pub fn log(message: impl Into<String>, request_id: u64) -> Self {
254        Self {
255            method: Self::LOG.into(),
256            params: serde_json::to_value(LogParams {
257                message: message.into(),
258                request_id,
259            })
260            .expect("LogParams serializes"),
261        }
262    }
263
264    /// Parse the payload as [`EventParams`], if this is an `event` notification.
265    pub fn as_event(&self) -> Option<EventParams> {
266        (self.method == Self::EVENT)
267            .then(|| serde_json::from_value(self.params.clone()).ok())
268            .flatten()
269    }
270
271    /// Parse the payload as [`LogParams`], if this is a `log` notification.
272    pub fn as_log(&self) -> Option<LogParams> {
273        (self.method == Self::LOG)
274            .then(|| serde_json::from_value(self.params.clone()).ok())
275            .flatten()
276    }
277}
278
279/// Typed payload of an `event` progress [`Notification`] for one in-flight run.
280///
281/// Correlated to its originating `run`/`execute` request by `request_id` — the
282/// same demultiplexing key responses use — so a host can bind progress to a
283/// specific call even when many cases (including repeated trials of one case)
284/// are multiplexed over the single pipe.
285#[derive(Clone, Debug, Default, Serialize, Deserialize)]
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287pub struct EventParams {
288    /// The `id` of the `run`/`execute` request this event belongs to. Defaulted
289    /// to 0 ("uncorrelated") so a study that omits it still parses.
290    #[serde(default)]
291    pub request_id: u64,
292    pub eval: String,
293    pub sample: String,
294    pub target: String,
295    /// Extra matrix-axis values for the case (empty for a target-only matrix).
296    #[serde(default, skip_serializing_if = "Params::is_empty")]
297    pub params: Params,
298    /// One of the [`event`] kinds. A future kind an older host doesn't recognise
299    /// is carried through verbatim (forward-compat), not rejected.
300    pub kind: String,
301    /// Reasoning-turn index, for an [`event::TURN`] event.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub turn: Option<usize>,
304    /// Tool name, for an [`event::TOOL_CALL`] event.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub tool: Option<String>,
307    /// Streamed output delta, for an [`event::OUTPUT`] event.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub text: Option<String>,
310}
311
312/// Typed payload of a `log` [`Notification`] — a free-form diagnostic line.
313#[derive(Clone, Debug, Default, Serialize, Deserialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct LogParams {
316    pub message: String,
317    /// The request this log relates to (0 for connection-level logs). Same
318    /// `#[serde(default)]` treatment as `EventParams::request_id`, so both carry a
319    /// consistent `default: 0` in the generated schema (no `skip_serializing_if`,
320    /// which would suppress the schema default and desync SDK generators).
321    #[serde(default)]
322    pub request_id: u64,
323}
324
325// ----- method payloads ------------------------------------------------------
326
327/// `initialize` result.
328#[derive(Clone, Debug, Serialize, Deserialize)]
329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
330pub struct InitializeResult {
331    pub protocol_version: String,
332    pub study: String,
333    pub evals: usize,
334    /// Optional study version string (e.g. the study crate's version). For
335    /// diagnostics; defaulted for forward/backward compatibility.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub study_version: Option<String>,
338    /// Named capabilities this study supports beyond the base methods, so hosts
339    /// can feature-detect additively (e.g. `"axes"`, `"events"`). Defaulted, so
340    /// an older study that omits it is treated as base-only.
341    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342    pub capabilities: Vec<String>,
343    /// Structured **config** for capabilities, keyed by capability token — the
344    /// data a bare [`capabilities`](Self::capabilities) string can't carry (which
345    /// event kinds the study emits, the input/output modalities it supports, a
346    /// concurrency hint, …). Open-vocabulary like `metadata`, so new keys need no
347    /// version bump; a host reads it additively, falling back to today's
348    /// behaviour when a token is absent.
349    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
350    pub capability_params: Metadata,
351}
352
353impl InitializeResult {
354    /// The structured config a study advertised for `capability`, if any (see
355    /// [`capability_params`](Self::capability_params)).
356    pub fn capability_param(&self, capability: &str) -> Option<&serde_json::Value> {
357        self.capability_params.get(capability)
358    }
359}
360
361/// Capability tokens a study may advertise in [`InitializeResult::capabilities`].
362///
363/// # Reserved (not yet implemented)
364/// `host_requests` is the negotiation handle for the **reverse channel** — a
365/// study→host request direction (host-brokered model access, shared resources,
366/// human-in-the-loop). It is *reserved*, not defined here: no const is minted and
367/// it is absent from the generated `meta.json` until the channel actually lands
368/// (then as a minor bump). The framing already accommodates it without a breaking
369/// change — see the `Inbound` classifier in [`crate::host`] and the
370/// reverse-channel seam in `docs/protocol.md` / `specs/architecture.md`.
371pub mod capabilities {
372    /// Study advertises extra matrix axes in `list` and honours `run.params`.
373    pub const AXES: &str = "axes";
374    /// Study emits `event` progress notifications during `run`.
375    pub const EVENTS: &str = "events";
376    /// Study reports token/cost usage and timing in transcripts.
377    pub const USAGE: &str = "usage";
378    /// Study answers `execute` (run the subject only, returning a full
379    /// transcript) for run-now-score-later workflows.
380    pub const EXECUTE: &str = "execute";
381    /// Study answers `score` (run scorers over a supplied transcript) for
382    /// deferred scoring and re-scoring of stored transcripts.
383    pub const SCORE: &str = "score";
384    /// Study honours the `trial`/`seed` run params — it threads the seed into the
385    /// subject so repetitions are reproducible. Trials run regardless (the host
386    /// drives the repetition); this advertises that seeding actually takes
387    /// effect, not just that the case is re-run.
388    pub const TRIALS: &str = "trials";
389    /// Study answers `cancel` (abort one in-flight run by its request `id`).
390    /// Without it, a host can only stop work by closing stdin, which ends every
391    /// in-flight run at once.
392    pub const CANCEL: &str = "cancel";
393    /// Study answers `list_samples` and may return a non-empty
394    /// `EvalInfo.next_cursor` from `list`, so the host pages large or lazily
395    /// generated sample sets instead of receiving them all in one `list`.
396    pub const PAGINATE: &str = "paginate";
397    /// Study attaches a structured ATIF trajectory to transcripts
398    /// (`Transcript::trajectory` on `execute` results / `score` params), and
399    /// its scorers can grade trajectory structure. The format/version pair
400    /// rides `capability_params` (`{"trajectory": {"format": "ATIF",
401    /// "version": "1.7"}}`), so a non-ATIF or ATIF-v2 representation needs no
402    /// new token. See [`crate::trajectory`].
403    pub const TRAJECTORY: &str = "trajectory";
404}
405
406/// Defined `event` notification kinds — the value of [`EventParams::kind`].
407///
408/// Like [`capabilities`], this is an **open, growing** token vocabulary, not a
409/// closed Rust enum: an older host must carry an unrecognised *future* kind
410/// through rather than fail to parse it (forward-compat contract #1), so `kind`
411/// stays a `String` and these constants name the stable vocabulary without
412/// freezing it. (Contrast [`crate::ErrorKind`], a closed two-state enum.)
413pub mod event {
414    /// A case's run has begun. Emitted once, before any other event for the case.
415    pub const STARTED: &str = "started";
416    /// A reasoning turn / iteration started; [`super::EventParams::turn`] carries
417    /// its index.
418    pub const TURN: &str = "turn";
419    /// A tool was invoked; [`super::EventParams::tool`] carries its name.
420    pub const TOOL_CALL: &str = "tool_call";
421    /// A chunk of streamed output; [`super::EventParams::text`] carries the delta.
422    pub const OUTPUT: &str = "output";
423    /// The case's run finished (success or error). Emitted once, last.
424    pub const FINISHED: &str = "finished";
425
426    /// Every defined kind, for the machine-readable `meta.json` index.
427    pub const ALL: &[&str] = &[STARTED, TURN, TOOL_CALL, OUTPUT, FINISHED];
428}
429
430#[derive(Clone, Debug, Serialize, Deserialize)]
431#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
432pub struct SampleInfo {
433    pub id: String,
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub tags: Vec<String>,
436    /// Free-form sample provenance (repo, difficulty, dataset split, …). Mirrors
437    /// `Sample::metadata` onto the wire so the host can group reports by it
438    /// (`--group-by`). Defaulted/omitted when empty, so an older study still parses.
439    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
440    pub metadata: Metadata,
441}
442
443#[derive(Clone, Debug, Serialize, Deserialize)]
444#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
445pub struct TargetInfo {
446    pub label: String,
447    /// Provider id (e.g. `sim`, `anthropic`, `openai`). Lets the host bucket
448    /// concurrency per provider so one provider's rate limits can't be flooded.
449    /// Defaulted (empty) so an older/foreign study that omits it still parses;
450    /// such cases share the empty-provider bucket.
451    #[serde(default, skip_serializing_if = "String::is_empty")]
452    pub provider: String,
453    /// False when a real provider's API key is absent in the study's env.
454    pub available: bool,
455    /// Free-form per-target config that rides the target column: agent, underlying
456    /// model, effort, price, sandbox, observability links, … Mirrors
457    /// `Target::metadata` onto the wire so the host can surface and group by
458    /// it. Defaulted/omitted when empty, so an older study still parses.
459    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
460    pub metadata: Metadata,
461}
462
463/// One extra matrix axis advertised by `list`, so the host can plan the full
464/// cross-product without running anything.
465#[derive(Clone, Debug, Serialize, Deserialize)]
466#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
467pub struct AxisInfo {
468    pub name: String,
469    pub values: Vec<String>,
470}
471
472/// One eval, as advertised by `list`. Enough for the host to plan the full
473/// `samples × targets` grid and apply selection without running anything.
474#[derive(Clone, Debug, Serialize, Deserialize)]
475#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
476pub struct EvalInfo {
477    pub name: String,
478    #[serde(default, skip_serializing_if = "String::is_empty")]
479    pub description: String,
480    /// The **first page** of this eval's samples. When `next_cursor` is present,
481    /// more pages follow — fetch them with `list_samples` (see
482    /// [`ListSamplesParams`]) until the cursor runs out. A study that fits its
483    /// whole dataset here omits `next_cursor`.
484    pub samples: Vec<SampleInfo>,
485    /// Opaque continuation token: present iff more samples remain beyond
486    /// `samples`. Pass it back via `list_samples` to fetch the next page. The
487    /// host treats it as an opaque blob — only the study interprets it.
488    /// Defaulted/omitted, so an older study that sends all samples inline (and a
489    /// host that ignores pagination) interoperate unchanged.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub next_cursor: Option<String>,
492    pub scorers: Vec<String>,
493    pub targets: Vec<TargetInfo>,
494    /// Extra matrix axes beyond the target. Defaulted so older servers that omit
495    /// the field still parse (forward compatibility).
496    #[serde(default, skip_serializing_if = "Vec::is_empty")]
497    pub axes: Vec<AxisInfo>,
498    /// Defaulted so a foreign/older study that omits it still parses, per the
499    /// protocol's forward-compatibility contract (see docs/protocol.md).
500    #[serde(default)]
501    pub max_turns: usize,
502    /// How many times each case of this eval should be run (trials/repetitions),
503    /// for pass@k / variance over a stochastic subject. `0`/`1` mean a single
504    /// run (no trial dimension). The host may override with `--trials`. Defaulted
505    /// so older/foreign studies that omit it still parse.
506    #[serde(default, skip_serializing_if = "is_single_trial")]
507    pub trials: usize,
508    /// Base seed the study declared for reproducible trials (trial `t` uses
509    /// `seed + t`). The host threads it into runs unless `--seed` overrides.
510    /// Defaulted/omitted when the study left seeding to the subject.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub seed: Option<u64>,
513    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
514    pub metadata: Metadata,
515}
516
517/// Serde skip helper: a trial count of `0` or `1` is a single, unrepeated run, so
518/// it's omitted on the wire (the common case stays clean).
519fn is_single_trial(n: &usize) -> bool {
520    *n <= 1
521}
522
523/// Serde skip helper for the 0-based `trial` index (omitted for the first/only).
524fn is_zero(n: &usize) -> bool {
525    *n == 0
526}
527
528#[derive(Clone, Debug, Serialize, Deserialize)]
529#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
530pub struct ListResult {
531    pub evals: Vec<EvalInfo>,
532}
533
534/// `list_samples` params: ask for one more page of `eval`'s samples, continuing
535/// from the opaque `cursor` last handed back (in [`EvalInfo::next_cursor`] or a
536/// prior [`ListSamplesResult::next_cursor`]). Only studies advertising the
537/// `paginate` capability answer this.
538#[derive(Clone, Debug, Serialize, Deserialize)]
539#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
540pub struct ListSamplesParams {
541    pub eval: String,
542    /// Opaque token from the previous page. The host echoes it verbatim.
543    pub cursor: String,
544}
545
546/// `list_samples` result: one page of samples plus the cursor for the page after
547/// it (`None` once the dataset is exhausted).
548#[derive(Clone, Debug, Serialize, Deserialize)]
549#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
550pub struct ListSamplesResult {
551    pub samples: Vec<SampleInfo>,
552    /// Opaque token for the next page, or `None` when this was the last page.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub next_cursor: Option<String>,
555}
556
557/// `run` params: address one matrix case by `(eval, sample, target label)` plus
558/// any extra axis `params` (axis name → chosen value).
559#[derive(Clone, Debug, Serialize, Deserialize)]
560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
561pub struct RunParams {
562    pub eval: String,
563    pub sample: String,
564    pub target: String,
565    /// Chosen value per extra matrix axis. Empty/omitted for a target-only
566    /// matrix; defaulted so older hosts/servers interoperate.
567    #[serde(default, skip_serializing_if = "Params::is_empty")]
568    pub params: Params,
569    /// 0-based trial index when this case is being repeated; `0` for a single
570    /// run. Defaulted so older hosts/studies interoperate.
571    #[serde(default, skip_serializing_if = "is_zero")]
572    pub trial: usize,
573    /// Total trials planned for this case (`0`/`1` = single run). Lets the study
574    /// echo the case's trial identity back so its key matches the host's plan.
575    #[serde(default, skip_serializing_if = "is_single_trial")]
576    pub trials: usize,
577    /// Per-trial seed for reproducibility, when the host set one. The study
578    /// threads it to the subject (see [`crate::Trial::seed`]).
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub seed: Option<u64>,
581}
582
583impl RunParams {
584    /// The [`Trial`](crate::Trial) this run addresses.
585    pub fn trial(&self) -> crate::Trial {
586        crate::Trial {
587            index: self.trial,
588            count: self.trials.max(1),
589            seed: self.seed,
590        }
591    }
592}
593
594/// `cancel` params: the `id` of the in-flight `run`/`execute`/`score` [`Request`]
595/// to abort. It is the request's own `id` (the one the host assigned and awaits a
596/// response on), not a case key — so a host can cancel a specific outstanding call
597/// even when several runs of the same case are in flight.
598#[derive(Clone, Debug, Serialize, Deserialize)]
599#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
600pub struct CancelParams {
601    pub id: u64,
602}
603
604/// `cancel` result: whether a matching in-flight request was found and aborted.
605/// `false` is normal and benign — the targeted request had already completed (or
606/// was never in flight) by the time the cancel arrived. Cancellation is therefore
607/// best-effort: a `run` that finishes first still returns its real result.
608#[derive(Clone, Debug, Serialize, Deserialize)]
609#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
610pub struct CancelResult {
611    pub cancelled: bool,
612}
613
614/// Lightweight transcript carried in results and checkpoints (the raw event
615/// stream is omitted to keep the artifact small).
616#[derive(Clone, Debug, Default, Serialize, Deserialize)]
617#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
618pub struct TranscriptSummary {
619    pub final_response: String,
620    pub iterations: usize,
621    pub tool_calls_count: usize,
622    #[serde(default, skip_serializing_if = "Vec::is_empty")]
623    pub tool_calls: Vec<String>,
624    pub usage: Usage,
625    #[serde(default, skip_serializing_if = "Timing::is_default")]
626    pub timing: Timing,
627    /// Custom open-vocabulary numeric metrics (see `Transcript::metrics`).
628    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
629    pub metrics: std::collections::BTreeMap<String, f64>,
630    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
631    pub metadata: Metadata,
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub error: Option<String>,
634    /// Classifies `error` (subject vs. infrastructure). Lets the host retry
635    /// infra-errored cases. Defaulted/omitted for the common subject case.
636    #[serde(default, skip_serializing_if = "crate::ErrorKind::is_subject")]
637    pub error_kind: crate::ErrorKind,
638    /// The run's multimodal output parts (see
639    /// [`Transcript::output`](crate::Transcript::output)), carried in the
640    /// lightweight summary so results/checkpoints retain the non-text modalities,
641    /// not just `final_response`. Empty for the common text-only case.
642    #[serde(default, skip_serializing_if = "Vec::is_empty")]
643    pub output: Vec<crate::Part>,
644    /// EXPERIMENTAL (gated behind `protocol-unstable`): reserved staging slot for
645    /// the next *structural* wire addition — the kind the open `metrics`/`metadata`
646    /// maps can't express (those carry numeric/string key-values; a new typed
647    /// field or nested shape still needs staging). The worked example of the
648    /// unstable convention: present on the wire and in the generated schema only
649    /// when the feature is enabled, so it can be trialled before promotion. A
650    /// placeholder — replace it with the real addition; don't depend on it.
651    #[cfg(feature = "protocol-unstable")]
652    #[serde(default, skip_serializing_if = "Option::is_none")]
653    pub experimental: Option<String>,
654}
655
656impl TranscriptSummary {
657    /// Project a full [`Transcript`] onto the lightweight wire/checkpoint form,
658    /// dropping the raw `events` and captured `files` to keep results small.
659    pub fn of(t: &Transcript) -> Self {
660        Self {
661            final_response: t.final_response.clone(),
662            iterations: t.iterations,
663            tool_calls_count: t.tool_calls_count,
664            tool_calls: t.tool_calls.clone(),
665            usage: t.usage,
666            timing: t.timing,
667            metrics: t.metrics.clone(),
668            metadata: t.metadata.clone(),
669            error: t.error.clone(),
670            error_kind: t.error_kind,
671            output: t.output.clone(),
672            // No source on the core `Transcript` yet — left unset until promoted.
673            #[cfg(feature = "protocol-unstable")]
674            experimental: None,
675        }
676    }
677}
678
679/// `execute` result for one case: the **full** [`Transcript`] (raw events and
680/// captured files included), so the host can persist it as an execution
681/// artifact and `score` it later. Distinct from [`RunResult`], which carries the
682/// lightweight [`TranscriptSummary`] plus scores.
683#[derive(Clone, Debug, Serialize, Deserialize)]
684#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
685pub struct ExecuteResult {
686    pub eval: String,
687    pub sample: String,
688    pub target: String,
689    /// Extra matrix-axis values for this case (empty for a target-only matrix).
690    #[serde(default, skip_serializing_if = "Params::is_empty")]
691    pub params: Params,
692    /// 0-based trial index when this case is repeated (see [`RunParams::trial`]).
693    #[serde(default, skip_serializing_if = "is_zero")]
694    pub trial: usize,
695    /// Total trials for this case (`0`/`1` = single run). Part of the case key
696    /// when `> 1`, so trial artifacts stay distinct.
697    #[serde(default, skip_serializing_if = "is_single_trial")]
698    pub trials: usize,
699    /// Per-trial seed this transcript was produced with, when set.
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub seed: Option<u64>,
702    /// The complete transcript, unlike the summary carried in [`RunResult`].
703    pub transcript: Transcript,
704    /// True when the case was not executed (e.g. target unavailable).
705    #[serde(default)]
706    pub skipped: bool,
707}
708
709impl ExecuteResult {
710    /// Stable case identity (see [`RunResult::key`]), trial-aware.
711    pub fn key(&self) -> String {
712        format!(
713            "{}{}",
714            crate::case_key(&self.eval, &self.sample, &self.target, &self.params),
715            crate::trial_suffix(self.trial, self.trials),
716        )
717    }
718}
719
720/// `score` params: a case identity plus the full [`Transcript`] to score. The
721/// transcript travels over the wire so the host can replay a stored one — the
722/// study scores it without re-running the subject.
723#[derive(Clone, Debug, Serialize, Deserialize)]
724#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
725pub struct ScoreParams {
726    pub eval: String,
727    pub sample: String,
728    pub target: String,
729    #[serde(default, skip_serializing_if = "Params::is_empty")]
730    pub params: Params,
731    /// 0-based trial index for the case this transcript came from (echoed into
732    /// the resulting [`RunResult`] so it keeps its trial identity on re-score).
733    #[serde(default, skip_serializing_if = "is_zero")]
734    pub trial: usize,
735    /// Total trials for this case (`0`/`1` = single run).
736    #[serde(default, skip_serializing_if = "is_single_trial")]
737    pub trials: usize,
738    /// Per-trial seed this transcript was produced with, when set.
739    #[serde(default, skip_serializing_if = "Option::is_none")]
740    pub seed: Option<u64>,
741    pub transcript: Transcript,
742}
743
744/// `run` result for one case. Also the unit persisted in checkpoints.
745#[derive(Clone, Debug, Serialize, Deserialize)]
746#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
747pub struct RunResult {
748    pub eval: String,
749    pub sample: String,
750    pub target: String,
751    /// Extra matrix-axis values for this case (empty for a target-only matrix).
752    #[serde(default, skip_serializing_if = "Params::is_empty")]
753    pub params: Params,
754    /// 0-based trial index when this case is repeated (see [`RunParams::trial`]).
755    #[serde(default, skip_serializing_if = "is_zero")]
756    pub trial: usize,
757    /// Total trials for this case (`0`/`1` = single run). Part of the case key
758    /// when `> 1`; the host groups results by their *logical* key (without the
759    /// trial suffix) to aggregate pass@k / variance (see [`crate::aggregate`]).
760    #[serde(default, skip_serializing_if = "is_single_trial")]
761    pub trials: usize,
762    /// Per-trial seed this result was produced with, when set.
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub seed: Option<u64>,
765    /// The sample's input turns (the prompt sent), copied through so a persisted
766    /// result is self-describing without the dataset. Empty when unavailable
767    /// (e.g. a synthetic error result).
768    #[serde(default, skip_serializing_if = "Vec::is_empty")]
769    pub input: Vec<String>,
770    /// The sample's expected/reference value, when the dataset provides one.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub expected: Option<serde_json::Value>,
773    pub passed: bool,
774    pub aggregate: f64,
775    pub scores: Vec<Score>,
776    pub transcript: TranscriptSummary,
777    /// True when the case was not executed (e.g. target unavailable).
778    #[serde(default)]
779    pub skipped: bool,
780}
781
782impl RunResult {
783    /// Stable case identity: `eval/sample@target` (with an `[k=v,…]` axis suffix
784    /// and a `#trial` suffix when this case is repeated). Used for selection,
785    /// dedupe, and checkpoint resume.
786    pub fn key(&self) -> String {
787        format!(
788            "{}{}",
789            self.logical_key(),
790            crate::trial_suffix(self.trial, self.trials)
791        )
792    }
793
794    /// The case identity **without** the `#trial` suffix — the key all trials of
795    /// one case share, so [`crate::aggregate`] can group them.
796    pub fn logical_key(&self) -> String {
797        crate::case_key(&self.eval, &self.sample, &self.target, &self.params)
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    // Exercises the `protocol-unstable` staging mechanism: when the feature is
806    // on, the experimental field is part of the wire type and round-trips. The
807    // committed schema (generated *without* the feature) must not contain it —
808    // see `unstable_field_absent_from_stable_schema` in mira-schema-gen.
809    #[cfg(feature = "protocol-unstable")]
810    #[test]
811    fn unstable_field_roundtrips_when_enabled() {
812        let t = TranscriptSummary {
813            experimental: Some("staged".into()),
814            ..Default::default()
815        };
816        let line = serde_json::to_string(&t).unwrap();
817        assert!(line.contains("experimental"));
818        let back: TranscriptSummary = serde_json::from_str(&line).unwrap();
819        assert_eq!(back.experimental.as_deref(), Some("staged"));
820    }
821
822    #[test]
823    fn request_response_roundtrip() {
824        let req = Request {
825            id: 7,
826            method: "run".into(),
827            params: serde_json::json!({"eval": "e"}),
828        };
829        let line = serde_json::to_string(&req).unwrap();
830        let back: Request = serde_json::from_str(&line).unwrap();
831        assert_eq!(back.id, 7);
832        assert_eq!(back.method, "run");
833    }
834
835    #[test]
836    fn cancel_params_and_result_roundtrip() {
837        let p = CancelParams { id: 42 };
838        let line = serde_json::to_string(&p).unwrap();
839        assert_eq!(line, r#"{"id":42}"#);
840        let back: CancelParams = serde_json::from_value(serde_json::json!({ "id": 42 })).unwrap();
841        assert_eq!(back.id, 42);
842
843        let r = CancelResult { cancelled: true };
844        let back: CancelResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
845        assert!(back.cancelled);
846    }
847
848    #[test]
849    fn version_compatibility() {
850        assert!(version_compatible(PROTOCOL_VERSION));
851        // 1.1 (trajectory) is additive: a 1.0 study/host keeps interoperating.
852        assert!(version_compatible(MIN_PROTOCOL_VERSION));
853        assert!(version_compatible("1.0"));
854        assert!(version_compatible("1.5")); // a future minor, same major
855        assert!(!version_compatible("2.0")); // newer major
856        assert!(!version_compatible("0.9")); // older major
857        assert_eq!(version_major("1.4"), 1);
858        assert_eq!(version_major("garbage"), 0);
859    }
860
861    #[test]
862    fn unknown_fields_are_ignored_for_forward_compat() {
863        // A future study adds fields the host doesn't know — must still parse.
864        let line = r#"{"protocol_version":"1.1","study":"x","evals":2,
865            "capabilities":["axes","future_thing"],"brand_new_field":{"a":1}}"#;
866        let info: InitializeResult = serde_json::from_str(line).unwrap();
867        assert_eq!(info.evals, 2);
868        assert!(info.capabilities.contains(&"axes".to_string()));
869    }
870
871    #[test]
872    fn eval_info_defaults_missing_optional_fields() {
873        // A foreign/older study (e.g. the Python example) omits max_turns, axes,
874        // description, and metadata. Per the forward-compat contract it must parse.
875        let line = r#"{"name":"greet","samples":[{"id":"hi"}],
876            "scorers":["succeeded"],"targets":[{"label":"sim","available":true}]}"#;
877        let info: EvalInfo = serde_json::from_str(line).unwrap();
878        assert_eq!(info.max_turns, 0);
879        assert!(info.axes.is_empty());
880        assert_eq!(info.samples.len(), 1);
881        // The per-sample / per-target metadata defaults to empty.
882        assert!(info.samples[0].metadata.is_empty());
883        assert!(info.targets[0].metadata.is_empty());
884    }
885
886    #[test]
887    fn sample_and_model_metadata_omitted_when_empty() {
888        // Forward-compat: a study that sets no sample/target metadata must omit
889        // the `metadata` key entirely, so an older host reading it sees nothing
890        // new. `skip_serializing_if` guarantees this.
891        let sample = serde_json::to_string(&SampleInfo {
892            id: "hi".into(),
893            tags: vec![],
894            metadata: Default::default(),
895        })
896        .unwrap();
897        assert!(!sample.contains("metadata"), "got: {sample}");
898        let target = serde_json::to_string(&TargetInfo {
899            label: "sim".into(),
900            provider: "sim".into(),
901            available: true,
902            metadata: Default::default(),
903        })
904        .unwrap();
905        assert!(!target.contains("metadata"), "got: {target}");
906    }
907
908    #[test]
909    fn sample_and_model_metadata_roundtrip() {
910        let mut metadata = Metadata::new();
911        metadata.insert("difficulty".into(), serde_json::json!("hard"));
912        metadata.insert("retries".into(), serde_json::json!(3));
913        let info = SampleInfo {
914            id: "hi".into(),
915            tags: vec!["smoke".into()],
916            metadata: metadata.clone(),
917        };
918        let back: SampleInfo =
919            serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
920        assert_eq!(back.metadata.get("difficulty").unwrap(), "hard");
921        assert_eq!(back.metadata.get("retries").unwrap(), &serde_json::json!(3));
922    }
923
924    #[test]
925    fn event_notification_roundtrips_and_correlates() {
926        let n = Notification::event(EventParams {
927            request_id: 42,
928            eval: "greet".into(),
929            sample: "hi".into(),
930            target: "sim".into(),
931            kind: event::TOOL_CALL.into(),
932            tool: Some("search".into()),
933            ..Default::default()
934        });
935        // A notification never carries the envelope `id` — that classifies a
936        // Response. The request id rides in the payload instead.
937        let line = serde_json::to_string(&n).unwrap();
938        assert!(!line.contains("\"id\""));
939        assert!(serde_json::from_str::<Response>(&line).is_err());
940
941        let back: Notification = serde_json::from_str(&line).unwrap();
942        let ev = back.as_event().expect("parses as event");
943        assert_eq!(ev.request_id, 42);
944        assert_eq!(ev.kind, event::TOOL_CALL);
945        assert_eq!(ev.tool.as_deref(), Some("search"));
946        // A `log` is not an `event` and vice versa.
947        assert!(back.as_log().is_none());
948    }
949
950    #[test]
951    fn untyped_event_still_parses() {
952        // A study may emit events with no `request_id` and no typed payload
953        // fields. Forward-compat: the host must still parse them, defaulting
954        // the correlation id to 0 ("uncorrelated").
955        let line = r#"{"method":"event","params":
956            {"eval":"greet","sample":"hi","target":"sim","kind":"started"}}"#;
957        let n: Notification = serde_json::from_str(line).unwrap();
958        let ev = n.as_event().expect("legacy event parses");
959        assert_eq!(ev.request_id, 0);
960        assert_eq!(ev.kind, "started");
961    }
962
963    #[test]
964    fn log_notification_roundtrips() {
965        let n = Notification::log("warming up", 7);
966        let back: Notification = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap();
967        let log = back.as_log().expect("parses as log");
968        assert_eq!(log.message, "warming up");
969        assert_eq!(log.request_id, 7);
970
971        // An untyped log (no request_id) still parses, id defaulting to 0.
972        let legacy: Notification =
973            serde_json::from_str(r#"{"method":"log","params":{"message":"hi"}}"#).unwrap();
974        let log = legacy.as_log().unwrap();
975        assert_eq!(log.message, "hi");
976        assert_eq!(log.request_id, 0);
977    }
978
979    #[test]
980    fn eval_info_without_cursor_omits_it_and_defaults_on_read() {
981        // A study that fits all samples inline must not emit `next_cursor`, and
982        // an older study that never knew the field still parses (defaults None).
983        let info = EvalInfo {
984            name: "greet".into(),
985            description: String::new(),
986            samples: vec![SampleInfo {
987                id: "hi".into(),
988                tags: vec![],
989                metadata: Metadata::default(),
990            }],
991            next_cursor: None,
992            scorers: vec![],
993            targets: vec![],
994            axes: vec![],
995            max_turns: 0,
996            trials: 0,
997            seed: None,
998            metadata: Metadata::default(),
999        };
1000        let line = serde_json::to_string(&info).unwrap();
1001        assert!(!line.contains("next_cursor"));
1002        let minimal = r#"{"name":"greet","samples":[{"id":"hi"}],
1003            "scorers":[],"targets":[]}"#;
1004        let back: EvalInfo = serde_json::from_str(minimal).unwrap();
1005        assert!(back.next_cursor.is_none());
1006    }
1007
1008    #[test]
1009    fn list_samples_roundtrips_with_and_without_next() {
1010        let params = ListSamplesParams {
1011            eval: "swe".into(),
1012            cursor: "500".into(),
1013        };
1014        let back: ListSamplesParams =
1015            serde_json::from_str(&serde_json::to_string(&params).unwrap()).unwrap();
1016        assert_eq!(back.eval, "swe");
1017        assert_eq!(back.cursor, "500");
1018
1019        let last = ListSamplesResult {
1020            samples: vec![SampleInfo {
1021                id: "case-999".into(),
1022                tags: vec![],
1023                metadata: Metadata::default(),
1024            }],
1025            next_cursor: None,
1026        };
1027        let line = serde_json::to_string(&last).unwrap();
1028        assert!(!line.contains("next_cursor")); // omitted on the final page
1029        let more = ListSamplesResult {
1030            samples: vec![],
1031            next_cursor: Some("1000".into()),
1032        };
1033        let back: ListSamplesResult =
1034            serde_json::from_str(&serde_json::to_string(&more).unwrap()).unwrap();
1035        assert_eq!(back.next_cursor.as_deref(), Some("1000"));
1036    }
1037
1038    #[test]
1039    fn notification_has_no_id() {
1040        let n = Notification {
1041            method: "event".into(),
1042            params: serde_json::json!({"kind": "started"}),
1043        };
1044        let line = serde_json::to_string(&n).unwrap();
1045        assert!(!line.contains("\"id\""));
1046        // A notification must not parse as a Response (no id).
1047        assert!(serde_json::from_str::<Response>(&line).is_err());
1048    }
1049
1050    #[test]
1051    fn rpc_error_is_classifiable_and_roundtrips() {
1052        let err = RpcError::new("provider 503")
1053            .with_code(codes::INTERNAL_ERROR)
1054            .retryable()
1055            .with_data(serde_json::json!({ "provider": "anthropic" }));
1056        let line = serde_json::to_string(&err).unwrap();
1057        let back: RpcError = serde_json::from_str(&line).unwrap();
1058        assert!(back.retryable);
1059        assert_eq!(back.code, codes::INTERNAL_ERROR);
1060        assert_eq!(
1061            back.data,
1062            Some(serde_json::json!({ "provider": "anthropic" }))
1063        );
1064        // Default constructor is non-retryable and carries no data.
1065        let plain = RpcError::new("nope");
1066        assert!(!plain.retryable);
1067        assert!(!serde_json::to_string(&plain).unwrap().contains("data"));
1068    }
1069
1070    #[test]
1071    fn rpc_error_backward_compatible_with_bare_message() {
1072        // A peer sends only `message`; the optional fields default.
1073        let back: RpcError = serde_json::from_str(r#"{"message":"no such eval"}"#).unwrap();
1074        assert_eq!(back.message, "no such eval");
1075        assert_eq!(back.code, 0);
1076        assert!(!back.retryable);
1077        assert!(back.data.is_none());
1078    }
1079
1080    #[test]
1081    fn run_result_key() {
1082        let r = RunResult {
1083            eval: "greet".into(),
1084            sample: "hi".into(),
1085            target: "sim".into(),
1086            params: Default::default(),
1087            trial: 0,
1088            trials: 0,
1089            seed: None,
1090            input: Vec::new(),
1091            expected: None,
1092            passed: true,
1093            aggregate: 1.0,
1094            scores: vec![],
1095            transcript: TranscriptSummary::default(),
1096            skipped: false,
1097        };
1098        assert_eq!(r.key(), "greet/hi@sim");
1099
1100        let mut params = Params::new();
1101        params.insert("effort".into(), "high".into());
1102        let r2 = RunResult {
1103            params,
1104            ..r.clone()
1105        };
1106        assert_eq!(r2.key(), "greet/hi@sim[effort=high]");
1107    }
1108
1109    #[test]
1110    fn run_result_trial_key_and_logical_key() {
1111        // A repeated case (trials > 1) carries a `#index` suffix in its key, but
1112        // all trials share one logical key so the host can group them.
1113        let r = RunResult {
1114            eval: "greet".into(),
1115            sample: "hi".into(),
1116            target: "sim".into(),
1117            params: Default::default(),
1118            trial: 2,
1119            trials: 5,
1120            seed: Some(42),
1121            input: Vec::new(),
1122            expected: None,
1123            passed: true,
1124            aggregate: 1.0,
1125            scores: vec![],
1126            transcript: TranscriptSummary::default(),
1127            skipped: false,
1128        };
1129        assert_eq!(r.key(), "greet/hi@sim#2");
1130        assert_eq!(r.logical_key(), "greet/hi@sim");
1131
1132        // A single-trial case (trials <= 1) keeps the plain key — backward compat.
1133        let single = RunResult {
1134            trial: 0,
1135            trials: 1,
1136            ..r.clone()
1137        };
1138        assert_eq!(single.key(), "greet/hi@sim");
1139    }
1140
1141    #[test]
1142    fn pre_trials_payloads_parse_as_single_trial() {
1143        // A study may omit trial/trials/seed entirely. The host must parse
1144        // such a RunResult and treat it as a single, unrepeated case (plain key).
1145        let line = r#"{"eval":"greet","sample":"hi","target":"sim","passed":true,
1146            "aggregate":1.0,"scores":[],
1147            "transcript":{"final_response":"hi","iterations":1,"tool_calls_count":0,
1148            "usage":{"input_tokens":1,"output_tokens":1,"cost_usd":0.0}}}"#;
1149        let r: RunResult = serde_json::from_str(line).unwrap();
1150        assert_eq!(r.trial, 0);
1151        assert_eq!(r.trials, 0);
1152        assert_eq!(r.seed, None);
1153        assert_eq!(r.key(), "greet/hi@sim"); // no `#trial` suffix
1154        assert_eq!(r.logical_key(), "greet/hi@sim");
1155
1156        // Likewise an EvalInfo may omit trials/seed.
1157        let line = r#"{"name":"greet","samples":[{"id":"hi"}],"scorers":["s"],
1158            "targets":[{"label":"sim","available":true}]}"#;
1159        let e: EvalInfo = serde_json::from_str(line).unwrap();
1160        assert_eq!(e.trials, 0); // host clamps 0 → 1 (single run)
1161        assert_eq!(e.seed, None);
1162    }
1163
1164    #[test]
1165    fn trial_fields_omitted_on_wire_for_single_run() {
1166        // The common single-trial case adds nothing to the wire: no trial/trials/
1167        // seed keys when unrepeated and unseeded.
1168        let p = RunParams {
1169            eval: "e".into(),
1170            sample: "s".into(),
1171            target: "m".into(),
1172            params: Default::default(),
1173            trial: 0,
1174            trials: 1,
1175            seed: None,
1176        };
1177        let line = serde_json::to_string(&p).unwrap();
1178        assert!(!line.contains("trial"));
1179        assert!(!line.contains("seed"));
1180
1181        // A real trial serializes its fields and round-trips.
1182        let p2 = RunParams {
1183            trial: 3,
1184            trials: 8,
1185            seed: Some(7),
1186            ..p
1187        };
1188        let line2 = serde_json::to_string(&p2).unwrap();
1189        let back: RunParams = serde_json::from_str(&line2).unwrap();
1190        assert_eq!(back.trial, 3);
1191        assert_eq!(back.trials, 8);
1192        assert_eq!(back.seed, Some(7));
1193        assert_eq!(back.trial().count, 8);
1194    }
1195}