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(¶ms).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}