Skip to main content

mira/
lib.rs

1//! Mira — a Rust-first, code-first evaluation framework for agents and tools.
2//!
3//! Mira is a developer tool shaped like a test runner. You define evals in Rust
4//! (or any language that speaks the [protocol]), and a generic host CLI runs
5//! them across a **target** matrix, scores the results, and reports.
6//!
7//! # The model
8//!
9//! ```text
10//! Eval = Dataset(Sample…) + Subject + [Scorer…]  ×  target matrix
11//! ```
12//!
13//! * [`Sample`] — one dataset row: input turns, an optional `target`, seeded
14//!   `files`, `tags`, and free-form `metadata`.
15//! * [`Subject`] — the thing under evaluation. One adapter per
16//!   *shape*: an in-process closure ([`subject_fn`]), an
17//!   external binary ([`CliSubject`], the polyglot path),
18//!   or a custom integration such as `mira-everruns`'s `RuntimeSubject`.
19//! * [`Transcript`] — the normalized result every subject produces, so scorers
20//!   and reporting are shared.
21//! * [`Scorer`] — grades a [`Transcript`] into a [`Score`].
22//!   Deterministic built-ins, an arbitrary-closure escape hatch, and
23//!   LLM-as-judge ([`model_graded`](scorer::model_graded)) compose freely.
24//! * [`Target`] — one case of the matrix. Provider-agnostic;
25//!   missing API keys mark a case unavailable so it is *skipped*, not failed.
26//!
27//! # Two ways to run
28//!
29//! * **In process** — build [`Eval`]s and drive them with a [`Runner`]. Best for
30//!   unit-style evals that live next to the code under test.
31//! * **Over the protocol** — your program is a [`Study`]: it bundles evals and
32//!   calls [`serve_blocking`](Study::serve_blocking) (or
33//!   [`serve`](Study::serve) from an async `main`) to expose them. The `mira` host CLI ([`Host`])
34//!   compiles/spawns it, plans the run, and owns selection, the matrix,
35//!   run storage, and reporting. Provider keys never cross the wire — models are
36//!   addressed by *label*. See [`protocol`].
37//!
38//! See the crate `examples/` (`greet`, `coding`, `cli_subject`) for runnable
39//! studies.
40
41// Boxed async-closure aliases (judge, subject factories) are the idiomatic way
42// to express async callbacks behind trait objects here.
43#![allow(clippy::type_complexity)]
44#![forbid(unsafe_code)]
45
46pub mod aggregate;
47pub mod content;
48pub mod dataset;
49pub mod eval;
50pub mod exec;
51pub mod glob;
52pub mod host;
53pub mod protocol;
54pub mod registry;
55pub mod report;
56pub mod run;
57pub mod runner;
58pub mod scorer;
59pub mod study;
60pub mod subject;
61pub mod target;
62pub mod trajectory;
63
64use std::collections::BTreeMap;
65
66use serde::{Deserialize, Serialize};
67
68// Re-exported so the `register_eval!` macro can reference `$crate::inventory`
69// without users taking a direct dependency on it.
70#[doc(hidden)]
71pub use inventory;
72
73/// The `#[eval]` attribute: registers a `fn() -> Eval` factory for
74/// `cargo test`-style discovery (the ergonomic form of [`register_eval!`]).
75///
76/// ```
77/// use mira::{eval, Eval, Transcript};
78/// use mira::subject::subject_fn;
79/// use mira::scorer::contains;
80///
81/// #[eval]
82/// fn greet() -> Eval {
83///     Eval::new("greet")
84///         .sample("hi", "say hi")
85///         .subject(subject_fn(|_, _| async { Transcript::response("hi there") }))
86///         .scorer(contains("hi"))
87///         .build()
88/// }
89/// ```
90#[cfg(feature = "macros")]
91pub use mira_macros::eval;
92
93pub use aggregate::{TrialAggregate, aggregate_trials};
94pub use content::{Message, Part, Role, Source};
95pub use dataset::{Dataset, Sample};
96pub use eval::Eval;
97pub use exec::{Concurrency, run_cases};
98pub use glob::glob_match;
99pub use host::{Host, HostHandle};
100pub use target::Target;
101// `register_eval!` is exported at the crate root via `#[macro_export]`.
102pub use registry::registered_evals;
103pub use run::{RunMeta, RunSummary, new_run_id, new_run_id_at, now_unix};
104pub use runner::{CaseOutcome, RunReport, Runner};
105pub use scorer::Scorer;
106pub use study::Study;
107pub use subject::{CliSubject, Subject, subject_fn};
108pub use trajectory::{ToolInvocation, Trajectory};
109
110/// Free-form, **open-ended** metadata attached to evals, samples, targets,
111/// transcripts, and runs.
112///
113/// Keys are arbitrary; values are arbitrary JSON ([`serde_json::Value`]) — a
114/// string, number, bool, or a nested object/array — so callers can attach
115/// structured context (trace URLs, dashboard deep-links, commit SHAs, dataset
116/// provenance, nested provider details) without the protocol modelling each
117/// shape. Carried through the protocol untouched and surfaced in reports. Use
118/// [`metrics`](Transcript::metrics) instead for values you want to *compare*
119/// numerically.
120pub type Metadata = BTreeMap<String, serde_json::Value>;
121
122/// Matrix-axis values for one case: axis name → chosen value.
123///
124/// Unlike [`Metadata`], these are always plain strings — they form part of the
125/// case key ([`case_key`]) and the selection grammar, so they stay scalar and
126/// stable rather than open-ended.
127pub type Params = BTreeMap<String, String>;
128
129/// One trial's reproducibility context: which repetition this case run is
130/// (`index` of `count`) and the seed handed to the subject, if any.
131///
132/// **Trials are repetitions of the *same* logical case** — unlike an [axis], they
133/// don't form new cases, they're re-runs grouped back together so the host can
134/// compute pass@k, pass-rate, and score variance (see [`crate::aggregate`]).
135/// A `seed` makes a trial reproducible: a subject seeds its RNG / sampling
136/// temperature from it so the same `(case, seed)` replays identically.
137///
138/// The single, unrepeated run is [`Trial::single`] (`count == 1`); it carries no
139/// trial dimension, so it adds no `#index` suffix to the case key.
140///
141/// [axis]: crate::eval::Axis
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub struct Trial {
144    /// 0-based repetition index within this case's trials.
145    pub index: usize,
146    /// Total repetitions planned for this case. `1` means no trial dimension.
147    pub count: usize,
148    /// Per-trial seed for reproducibility, when the run set one.
149    pub seed: Option<u64>,
150}
151
152impl Default for Trial {
153    fn default() -> Self {
154        Self::single()
155    }
156}
157
158impl Trial {
159    /// The single, unrepeated run: index 0 of 1, no seed.
160    pub fn single() -> Self {
161        Self {
162            index: 0,
163            count: 1,
164            seed: None,
165        }
166    }
167
168    /// True when this case runs more than once (the trial dimension is active).
169    pub fn is_repeated(&self) -> bool {
170        self.count > 1
171    }
172
173    /// The `#index` suffix this trial contributes to a case key, or empty when
174    /// the case isn't repeated — so single-trial runs keep their plain keys.
175    pub fn key_suffix(&self) -> String {
176        trial_suffix(self.index, self.count)
177    }
178}
179
180/// The `#index` key suffix for a `(trial, trials)` pair: present only when the
181/// case is repeated (`trials > 1`), so a single-trial case keeps the plain
182/// `eval/sample@target[…]` key. Host and study compute it identically.
183pub fn trial_suffix(trial: usize, trials: usize) -> String {
184    if trials > 1 {
185        format!("#{trial}")
186    } else {
187        String::new()
188    }
189}
190
191/// Render an open-ended [`Metadata`] value for display (reports, CLI): a JSON
192/// string yields its raw contents (no surrounding quotes); anything else yields
193/// its compact JSON form (`3`, `true`, `{"k":"v"}`).
194pub fn metadata_display(value: &serde_json::Value) -> String {
195    match value.as_str() {
196        Some(s) => s.to_string(),
197        None => value.to_string(),
198    }
199}
200
201/// Token / cost accounting, summed across all turns of a run.
202///
203/// Beyond raw input/output tokens, `cache_read_tokens` and `reasoning_tokens`
204/// capture the breakdowns modern providers report; they default to zero for
205/// subjects that don't surface them.
206#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208pub struct Usage {
209    pub input_tokens: u64,
210    pub output_tokens: u64,
211    /// Prompt tokens served from cache (a subset of `input_tokens` for providers
212    /// that bill them separately). Zero when not reported.
213    #[serde(default, skip_serializing_if = "is_zero_u64")]
214    pub cache_read_tokens: u64,
215    /// Reasoning / thinking tokens (a subset of `output_tokens`). Zero when not
216    /// reported.
217    #[serde(default, skip_serializing_if = "is_zero_u64")]
218    pub reasoning_tokens: u64,
219    pub cost_usd: f64,
220}
221
222fn is_zero_u64(v: &u64) -> bool {
223    *v == 0
224}
225
226impl Usage {
227    /// Total tokens (input + output).
228    pub fn total_tokens(&self) -> u64 {
229        self.input_tokens + self.output_tokens
230    }
231
232    /// Accumulate another usage record into this one.
233    pub fn add(&mut self, other: &Usage) {
234        self.input_tokens += other.input_tokens;
235        self.output_tokens += other.output_tokens;
236        self.cache_read_tokens += other.cache_read_tokens;
237        self.reasoning_tokens += other.reasoning_tokens;
238        self.cost_usd += other.cost_usd;
239    }
240}
241
242/// Wall-clock timing for a run. Subjects that can measure it populate these;
243/// the rest leave them at their defaults.
244#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
246pub struct Timing {
247    /// Total wall-clock duration of the run, in milliseconds.
248    #[serde(default, skip_serializing_if = "is_zero_u64")]
249    pub duration_ms: u64,
250    /// Time from run start to the first streamed token/event, in milliseconds,
251    /// when the subject can measure it (latency a user perceives first).
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub time_to_first_token_ms: Option<u64>,
254}
255
256impl Timing {
257    /// True when no timing was recorded (all fields at their defaults).
258    pub fn is_default(&self) -> bool {
259        *self == Timing::default()
260    }
261}
262
263/// Normalized result of running a [`Subject`] on one
264/// [`Sample`].
265///
266/// Every subject — in-process, CLI, or a custom integration — produces this same
267/// shape, so scorers and reporting never depend on a subject's internals.
268///
269/// Subjects that can produce a structured record of *what the agent did* set
270/// [`trajectory`](Transcript::trajectory) — the primary structured trajectory
271/// contract (ATIF; see [`crate::trajectory`]). **Provide `trajectory` and the
272/// rest is derived**: the flat fields (`final_response`, `tool_calls`,
273/// `iterations`, `usage`) are its projections, filled automatically by the
274/// framework wherever a transcript is produced or received (see
275/// [`Transcript::project_trajectory`]) — a trajectory-only transcript works
276/// with every existing scorer, no extra calls required. `events` is optional
277/// and fully independent of `trajectory`: providing one, the other, both, or
278/// neither are all valid, with no consistency obligation on the producer.
279#[derive(Clone, Debug, Default, Serialize, Deserialize)]
280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
281pub struct Transcript {
282    // The flat fields below are `#[serde(default)]` (but always serialized):
283    // a trajectory-only producer may omit every one of them and the wire
284    // still parses, with `project_trajectory` deriving them afterwards.
285    /// The subject's final response text.
286    #[serde(default)]
287    pub final_response: String,
288    /// Reasoning iterations / turns taken.
289    #[serde(default)]
290    pub iterations: usize,
291    /// Number of tool calls made.
292    #[serde(default)]
293    pub tool_calls_count: usize,
294    /// Token / cost usage.
295    #[serde(default)]
296    pub usage: Usage,
297    /// Wall-clock timing (duration, time-to-first-token).
298    #[serde(default, skip_serializing_if = "Timing::is_default")]
299    pub timing: Timing,
300    /// Best-effort list of tool names invoked, in order.
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub tool_calls: Vec<String>,
303    /// Files present in the subject's workspace after the run (path → contents).
304    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
305    pub files: BTreeMap<String, String>,
306    /// Structured ATIF trajectory of the run (steps with tool calls,
307    /// arguments, observations, per-step metrics) — the **primary structured
308    /// trajectory contract**. Optional: subjects that can produce it do;
309    /// text-only subjects omit it. When present, `final_response`,
310    /// `tool_calls`, `iterations`, and `usage` are projections of it, derived
311    /// automatically ([`trajectory::Trajectory::project_into`], applied by the
312    /// framework on produce/receive) — a producer sets this field alone and
313    /// owes nothing else, `events` included. See [`crate::trajectory`].
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub trajectory: Option<trajectory::Trajectory>,
316    /// **Advanced / secondary**: raw, producer-shaped debug events (e.g. the
317    /// everruns `Event` JSONL transcript). No cross-subject shape — scorers
318    /// and consumers must prefer [`trajectory`](Transcript::trajectory) for
319    /// anything it models (tool calls, arguments, observations, metrics);
320    /// `events` is only for debugging and data the trajectory doesn't carry.
321    /// Optional and independent of `trajectory` (either, both, or neither).
322    #[serde(default, skip_serializing_if = "Vec::is_empty")]
323    pub events: Vec<serde_json::Value>,
324    /// Extensible **numeric** metrics a subject measured that the core doesn't
325    /// model as a typed field (recall@k, energy_joules, p95 latency, …).
326    ///
327    /// Design: `Usage`/`Timing` stay typed because shared budget scorers depend
328    /// on their exact shape; everything else is an *open vocabulary* keyed by
329    /// name so a subject can report a *new metric key* and grade it with
330    /// [`metric_within`]/[`metric_at_least`] without a new protocol version (the
331    /// `metrics` map itself is a versioned, additive part of the wire). Use this
332    /// (not `metadata`) for anything you want to compare numerically — values
333    /// stay `f64`, surface in the JSON/HTML reports, and feed generic scorers.
334    ///
335    /// [`metric_within`]: crate::scorer::metric_within
336    /// [`metric_at_least`]: crate::scorer::metric_at_least
337    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
338    pub metrics: BTreeMap<String, f64>,
339    /// Multimodal output — the response as an ordered list of typed [`Part`]s
340    /// (text, image, audio, file, structured JSON) for subjects whose result
341    /// isn't plain text. `final_response` stays the canonical *text* projection
342    /// (a text-only scorer keeps working); `output` carries the modalities text
343    /// can't. Empty for the common text-only case.
344    #[serde(default, skip_serializing_if = "Vec::is_empty")]
345    pub output: Vec<Part>,
346    /// Free-form metadata: observability links, run ids, etc.
347    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
348    pub metadata: Metadata,
349    /// Set when the subject failed to complete the run.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub error: Option<String>,
352    /// Classifies `error`: a [`Subject`](ErrorKind::Subject) failure (the model
353    /// under test got it wrong — scored as a failure) vs. an
354    /// [`Infra`](ErrorKind::Infra) failure (budget, rate limit, provider outage,
355    /// timeout — not the model's fault). Defaulted/omitted for the common subject
356    /// case; meaningless when `error` is `None`.
357    #[serde(default, skip_serializing_if = "ErrorKind::is_subject")]
358    pub error_kind: ErrorKind,
359}
360
361/// Why a run failed, when it did — set alongside [`Transcript::error`].
362///
363/// A [`Subject`](ErrorKind::Subject) error is the model/agent *under test*
364/// getting it wrong: it ran but crashed on the input, produced garbage, or blew
365/// its turn budget — a real failure the eval should catch. An
366/// [`Infra`](ErrorKind::Infra) error is the scaffolding *around* the run breaking
367/// (out of budget/quota, rate-limited, a provider 5xx/outage, a network/timeout
368/// fault): not the model's fault. Infra failures are surfaced as **N/A**
369/// ([`Score::na`]) so they are excluded from the case verdict and aggregate
370/// (neither pass nor fail, like [`Score::na`] for a single scorer), and the host
371/// retries them up to `--max-retries`. See [`Transcript::infra_error`].
372#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
373#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
374#[serde(rename_all = "snake_case")]
375pub enum ErrorKind {
376    /// The subject/model under test errored: a real, scoreable failure.
377    #[default]
378    Subject,
379    /// The infrastructure around the run errored: not the model's fault, scored
380    /// N/A (not failed), and retryable.
381    Infra,
382}
383
384impl ErrorKind {
385    /// True for the default ([`Subject`](ErrorKind::Subject)); lets serde skip
386    /// the field on the wire for the common case.
387    pub fn is_subject(&self) -> bool {
388        matches!(self, ErrorKind::Subject)
389    }
390}
391
392impl Transcript {
393    /// A transcript whose only content is a final response. Convenience for
394    /// simple subjects and tests.
395    pub fn response(text: impl Into<String>) -> Self {
396        Self {
397            final_response: text.into(),
398            ..Default::default()
399        }
400    }
401
402    /// A failed transcript carrying an error message, attributed to the subject
403    /// under test ([`ErrorKind::Subject`]) — a real, scoreable failure. For an
404    /// *infrastructure* failure that should not be scored against the model, use
405    /// [`Transcript::infra_error`].
406    pub fn failed(error: impl Into<String>) -> Self {
407        Self {
408            error: Some(error.into()),
409            ..Default::default()
410        }
411    }
412
413    /// A transcript that failed for an *infrastructure* reason ([`ErrorKind::Infra`]):
414    /// budget/quota, rate limit, provider outage, network/timeout — not the
415    /// model's fault. Scoring short-circuits to **N/A** so the case is excluded
416    /// from pass/fail, and the host retries it.
417    pub fn infra_error(error: impl Into<String>) -> Self {
418        Self {
419            error: Some(error.into()),
420            error_kind: ErrorKind::Infra,
421            ..Default::default()
422        }
423    }
424
425    /// A transcript built from a structured ATIF [`Trajectory`] alone — the
426    /// zero-burden path for trajectory-producing subjects. The flat fields
427    /// (`final_response`, `tool_calls`, `iterations`, `usage`) are projected
428    /// from the trajectory automatically; there is nothing else to call, and
429    /// `events` is not required (it is independent of the trajectory).
430    pub fn from_trajectory(trajectory: trajectory::Trajectory) -> Self {
431        let mut t = Self::default();
432        trajectory.project_into(&mut t);
433        t.trajectory = Some(trajectory);
434        t
435    }
436
437    /// Fill any flat fields still at their defaults from
438    /// [`trajectory`](Transcript::trajectory) (no-op without one). Fields a
439    /// producer set explicitly are never overwritten — see
440    /// [`trajectory::Trajectory::project_into`]. The framework calls this at
441    /// every point a transcript is produced or received (subject execution,
442    /// `score` params, `execute` results), so a study that serializes
443    /// `{"trajectory": …}` and nothing else scores correctly end-to-end.
444    pub fn project_trajectory(&mut self) {
445        if let Some(trajectory) = self.trajectory.take() {
446            trajectory.project_into(self);
447            self.trajectory = Some(trajectory);
448        }
449    }
450
451    /// True when no error was recorded.
452    pub fn succeeded(&self) -> bool {
453        self.error.is_none()
454    }
455
456    /// True when this run hit an infrastructure error (see [`ErrorKind::Infra`]).
457    pub fn errored_infra(&self) -> bool {
458        self.error.is_some() && self.error_kind == ErrorKind::Infra
459    }
460
461    /// Distinct tool names invoked, in first-seen order. `tool_calls` keeps every
462    /// invocation (with repeats); this collapses to the unique set used.
463    pub fn tools_used(&self) -> Vec<String> {
464        let mut seen = Vec::new();
465        for name in &self.tool_calls {
466            if !seen.contains(name) {
467                seen.push(name.clone());
468            }
469        }
470        seen
471    }
472
473    /// Record wall-clock duration. Returns `self` for builder-style use in
474    /// subjects and tests.
475    pub fn with_duration_ms(mut self, ms: u64) -> Self {
476        self.timing.duration_ms = ms;
477        self
478    }
479
480    /// Record a custom numeric metric. Returns `self` for builder-style use:
481    /// `Transcript::response(text).with_metric("recall@5", 0.8)`.
482    ///
483    /// Non-finite values (`NaN`/`±inf`) are dropped rather than stored: JSON
484    /// can't represent them, so storing one would break report serialization.
485    /// The metric stays *unreported*, and a budget over it fails accordingly.
486    pub fn with_metric(mut self, name: impl Into<String>, value: f64) -> Self {
487        self.record_metric(name, value);
488        self
489    }
490
491    /// Record a custom numeric metric in place (for subjects that build the
492    /// transcript mutably). Non-finite values are dropped — see [`with_metric`].
493    ///
494    /// [`with_metric`]: Transcript::with_metric
495    pub fn record_metric(&mut self, name: impl Into<String>, value: f64) {
496        if value.is_finite() {
497            self.metrics.insert(name.into(), value);
498        }
499    }
500
501    /// Look up a custom numeric metric by name.
502    pub fn metric(&self, name: &str) -> Option<f64> {
503        self.metrics.get(name).copied()
504    }
505
506    /// Attach multimodal output parts, keeping `final_response` as the canonical
507    /// text projection. Builder-style: `Transcript::response(text).with_output(parts)`.
508    /// See [`Transcript::output`].
509    pub fn with_output(mut self, parts: impl IntoIterator<Item = Part>) -> Self {
510        self.output = parts.into_iter().collect();
511        self
512    }
513
514    /// The distinct output modalities present (`text`, `image`, …), in first-seen
515    /// order. Empty when no multimodal `output` was recorded.
516    /// See [`Transcript::output`].
517    pub fn output_modalities(&self) -> Vec<&'static str> {
518        content::modalities(&self.output)
519    }
520}
521
522/// Outcome of a single [`Scorer`] on a [`Transcript`].
523///
524/// `value` is a continuous score in `0.0..=1.0`; `pass` is the boolean verdict
525/// (often `value >= threshold`). Keeping both lets a scorer report a graded
526/// signal while still contributing a pass/fail to the matrix.
527///
528/// A third state — **N/A** ([`na`](Score::na)) — lets a scorer say "I couldn't
529/// evaluate this" (an unreachable judge, a missing API key, any infra hiccup)
530/// rather than crashing the run or lying with a `fail`. An N/A score is excluded
531/// from the case verdict and the aggregate: it neither passes nor fails.
532#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
533#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
534pub struct Score {
535    pub scorer: String,
536    pub value: f64,
537    pub pass: bool,
538    /// True when the scorer did not apply / could not run (infra issue, missing
539    /// credentials, …). Excluded from the case verdict and aggregate.
540    #[serde(default, skip_serializing_if = "is_false")]
541    pub na: bool,
542    pub reason: String,
543}
544
545fn is_false(b: &bool) -> bool {
546    !*b
547}
548
549impl Score {
550    /// A passing score (`value = 1.0`).
551    pub fn pass(scorer: impl Into<String>, reason: impl Into<String>) -> Self {
552        Self {
553            scorer: scorer.into(),
554            value: 1.0,
555            pass: true,
556            na: false,
557            reason: reason.into(),
558        }
559    }
560
561    /// A failing score (`value = 0.0`).
562    pub fn fail(scorer: impl Into<String>, reason: impl Into<String>) -> Self {
563        Self {
564            scorer: scorer.into(),
565            value: 0.0,
566            pass: false,
567            na: false,
568            reason: reason.into(),
569        }
570    }
571
572    /// A not-applicable score: the scorer could not be evaluated (e.g. the judge
573    /// model was unreachable or unconfigured). Counts as neither pass nor fail —
574    /// the case verdict and aggregate ignore it. This is the sanctioned way to
575    /// handle infra failures: return N/A instead of crashing or failing.
576    pub fn na(scorer: impl Into<String>, reason: impl Into<String>) -> Self {
577        Self {
578            scorer: scorer.into(),
579            value: 0.0,
580            pass: false,
581            na: true,
582            reason: reason.into(),
583        }
584    }
585
586    /// A graded score in `0.0..=1.0`; `pass` is `value >= threshold`.
587    pub fn graded(
588        scorer: impl Into<String>,
589        value: f64,
590        threshold: f64,
591        reason: impl Into<String>,
592    ) -> Self {
593        let value = value.clamp(0.0, 1.0);
594        Self {
595            scorer: scorer.into(),
596            value,
597            pass: value >= threshold,
598            na: false,
599            reason: reason.into(),
600        }
601    }
602
603    /// True when this score did not apply (see [`Score::na`]).
604    pub fn is_na(&self) -> bool {
605        self.na
606    }
607}
608
609/// Per-run context handed to a [`Subject`]: which target to use
610/// for this matrix case, and the run limits.
611#[derive(Clone, Debug)]
612pub struct RunCx {
613    /// The matrix case's target (the model or harness under evaluation).
614    pub target: Target,
615    /// Maximum reasoning iterations a subject should take.
616    pub max_turns: usize,
617    /// Values for any extra matrix axes this case varies (axis name → value),
618    /// e.g. `{"effort": "high"}`. Empty for a target-only matrix. A subject reads
619    /// these to vary its behaviour per case.
620    pub params: Params,
621    /// This run's trial within its case: which repetition (`index` of `count`)
622    /// and the optional seed. A stochastic subject seeds its RNG / sampling from
623    /// [`Trial::seed`] so the run is reproducible. [`Trial::single`] for an
624    /// unrepeated case.
625    pub trial: Trial,
626    /// The conversation so far, for an **interactive** (multi-turn) eval: the
627    /// alternating `User`/`Assistant` [`Message`]s leading up to this call, with
628    /// the latest `User` turn last. Empty on the first call and for single-shot
629    /// evals (the subject reads the [`Sample`] directly then). A multi-turn-aware
630    /// subject reconstructs its context from this each call (it is invoked once
631    /// per turn). Populated by the interactive driver; see [`Eval::responder`].
632    ///
633    /// [`Eval::responder`]: crate::eval::EvalBuilder::responder
634    pub conversation: Vec<Message>,
635}
636
637impl RunCx {
638    /// A context for `target` with default limits, no extra axis params, a single
639    /// (unrepeated, unseeded) trial, and an empty conversation.
640    pub fn new(target: Target) -> Self {
641        Self {
642            target,
643            max_turns: 12,
644            params: Params::new(),
645            trial: Trial::single(),
646            conversation: Vec::new(),
647        }
648    }
649
650    /// The value of an extra matrix axis for this case, if set.
651    pub fn param(&self, name: &str) -> Option<&str> {
652        self.params.get(name).map(String::as_str)
653    }
654
655    /// This run's seed, if the host set one (a convenience for
656    /// `self.trial.seed`). Seed a subject's RNG / sampling from this for
657    /// reproducible trials.
658    pub fn seed(&self) -> Option<u64> {
659        self.trial.seed
660    }
661}
662
663/// The canonical, stable identity of one matrix case: `eval/sample@target`,
664/// suffixed with `[k=v,…]` (axis params sorted by key) when extra axes vary.
665/// Used for selection, dedupe, checkpoint resume, and reporting — host and
666/// study compute it identically. `target` is the target label.
667pub fn case_key(eval: &str, sample: &str, target: &str, params: &Params) -> String {
668    let base = format!("{eval}/{sample}@{target}");
669    if params.is_empty() {
670        return base;
671    }
672    // BTreeMap iterates sorted by key, so the suffix is deterministic.
673    let suffix = params
674        .iter()
675        .map(|(k, v)| format!("{k}={v}"))
676        .collect::<Vec<_>>()
677        .join(",");
678    format!("{base}[{suffix}]")
679}
680
681/// Heuristic: does this error message look like a provider rate-limit / quota /
682/// overload signal? The core is provider-agnostic, so detection is a substring
683/// match over the common phrasings (HTTP 429, "rate limit", "overloaded",
684/// "quota", …). The host's adaptive scheduler uses this to back off and retry a
685/// case instead of failing it (see [`exec`]).
686pub fn is_rate_limited(message: &str) -> bool {
687    let m = message.to_ascii_lowercase();
688    m.contains("429")
689        || m.contains("rate limit")
690        || m.contains("rate-limit")
691        || m.contains("ratelimit")
692        || m.contains("too many requests")
693        || m.contains("overloaded")
694        || m.contains("quota")
695        || m.contains("try again later")
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[test]
703    fn usage_accumulates() {
704        let mut a = Usage {
705            input_tokens: 10,
706            output_tokens: 5,
707            cost_usd: 0.1,
708            ..Default::default()
709        };
710        a.add(&Usage {
711            input_tokens: 1,
712            output_tokens: 2,
713            reasoning_tokens: 4,
714            cost_usd: 0.01,
715            ..Default::default()
716        });
717        assert_eq!(a.input_tokens, 11);
718        assert_eq!(a.total_tokens(), 18);
719        assert_eq!(a.reasoning_tokens, 4);
720        assert!((a.cost_usd - 0.11).abs() < 1e-9);
721    }
722
723    #[test]
724    fn score_graded_respects_threshold() {
725        let s = Score::graded("s", 0.8, 0.7, "ok");
726        assert!(s.pass);
727        let s = Score::graded("s", 0.6, 0.7, "low");
728        assert!(!s.pass);
729        // Out-of-range values clamp.
730        assert_eq!(Score::graded("s", 2.0, 0.7, "").value, 1.0);
731    }
732
733    #[test]
734    fn na_score_is_neither_pass_nor_fail() {
735        let s = Score::na("judge", "model unreachable");
736        assert!(s.is_na());
737        assert!(!s.pass);
738        // N/A is carried through serialization so consumers can distinguish it.
739        let json = serde_json::to_string(&s).unwrap();
740        assert!(json.contains("\"na\":true"));
741        // A normal score omits the flag.
742        let p = Score::pass("s", "ok");
743        assert!(!serde_json::to_string(&p).unwrap().contains("na"));
744    }
745
746    #[test]
747    fn transcript_helpers() {
748        assert!(Transcript::response("hi").succeeded());
749        assert!(!Transcript::failed("boom").succeeded());
750    }
751
752    #[test]
753    fn infra_error_is_distinct_from_subject_error() {
754        let infra = Transcript::infra_error("budget exhausted");
755        assert!(!infra.succeeded());
756        assert!(infra.errored_infra());
757        assert_eq!(infra.error_kind, ErrorKind::Infra);
758
759        let subject = Transcript::failed("wrong answer");
760        assert!(!subject.succeeded());
761        assert!(!subject.errored_infra()); // a real failure, not infra
762        assert_eq!(subject.error_kind, ErrorKind::Subject);
763
764        assert!(!Transcript::response("ok").errored_infra());
765
766        // Subject (default) kind is omitted on the wire; Infra is serialized.
767        let subj = serde_json::to_string(&Transcript::failed("x")).unwrap();
768        assert!(!subj.contains("error_kind"));
769        let inf = serde_json::to_string(&Transcript::infra_error("x")).unwrap();
770        assert!(inf.contains("\"error_kind\":\"infra\""));
771    }
772
773    #[test]
774    fn detects_rate_limit_signals() {
775        assert!(is_rate_limited("HTTP 429 Too Many Requests"));
776        assert!(is_rate_limited("anthropic: overloaded_error"));
777        assert!(is_rate_limited("Rate limit exceeded, try again later"));
778        assert!(is_rate_limited("insufficient_quota"));
779        assert!(!is_rate_limited("invalid api key"));
780        assert!(!is_rate_limited("connection refused"));
781    }
782
783    #[test]
784    fn custom_metrics_round_trip_and_reject_non_finite() {
785        let t = Transcript::response("ok")
786            .with_metric("recall@5", 0.8)
787            .with_metric("nan", f64::NAN)
788            .with_metric("inf", f64::INFINITY);
789        // Finite values stored; non-finite dropped (so they stay "unreported").
790        assert_eq!(t.metric("recall@5"), Some(0.8));
791        assert_eq!(t.metric("nan"), None);
792        assert_eq!(t.metric("inf"), None);
793        // What we kept must serialize as JSON (non-finite floats would error).
794        serde_json::to_string(&t).expect("transcript with metrics serializes");
795    }
796
797    #[test]
798    fn multimodal_output_rides_alongside_text() {
799        let t = Transcript::response("a cat on a mat").with_output([
800            Part::text("a cat on a mat"),
801            Part::image_uri("image/png", "https://x/cat.png"),
802        ]);
803        // final_response stays the canonical text; output carries the modalities.
804        assert_eq!(t.final_response, "a cat on a mat");
805        assert_eq!(t.output_modalities(), vec!["text", "image"]);
806        // Round-trips on the committed wire.
807        let json = serde_json::to_string(&t).unwrap();
808        assert!(json.contains(r#""kind":"image""#));
809        let back: Transcript = serde_json::from_str(&json).unwrap();
810        assert_eq!(back.output, t.output);
811    }
812
813    #[test]
814    fn trajectory_only_transcript_round_trips_the_wire_and_projects() {
815        use crate::trajectory::{Agent, Step, StepSource, ToolCall, Trajectory};
816
817        // A producer (e.g. a polyglot study) serializes ONLY a trajectory —
818        // no flat fields, no events. That is a fully valid transcript.
819        let mut trajectory = Trajectory::new(Agent::new("test-agent", "1.0"));
820        let mut step = Step::new(1, StepSource::Agent, "the answer is 42");
821        step.tool_calls = vec![ToolCall::new(
822            "c1",
823            "calc",
824            serde_json::json!({"expr": "6*7"}),
825        )];
826        trajectory.steps.push(step);
827        let wire = serde_json::to_string(&serde_json::json!({ "trajectory": trajectory })).unwrap();
828
829        // Receive it off the wire, normalize, and the projections appear.
830        let mut t: Transcript = serde_json::from_str(&wire).unwrap();
831        assert!(t.final_response.is_empty()); // nothing until projected
832        t.project_trajectory();
833        assert_eq!(t.final_response, "the answer is 42");
834        assert_eq!(t.tool_calls, vec!["calc"]);
835        assert_eq!(t.tool_calls_count, 1);
836        assert_eq!(t.iterations, 1);
837        assert!(t.events.is_empty()); // never required alongside
838
839        // It round-trips: the trajectory survives re-serialization…
840        let again: Transcript = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
841        assert_eq!(again.trajectory, t.trajectory);
842        // …and projecting again is idempotent.
843        let mut twice = again.clone();
844        twice.project_trajectory();
845        assert_eq!(twice.tool_calls, again.tool_calls);
846
847        // A transcript without a trajectory omits the key entirely.
848        let plain = serde_json::to_string(&Transcript::response("ok")).unwrap();
849        assert!(!plain.contains("trajectory"));
850    }
851
852    #[test]
853    fn metadata_is_open_ended_and_round_trips() {
854        let mut t = Transcript::response("ok");
855        // Open-ended values: a string, a number, and a nested object.
856        t.metadata.insert("trace".into(), "https://obs/123".into());
857        t.metadata.insert("attempt".into(), 3.into());
858        t.metadata.insert(
859            "ctx".into(),
860            serde_json::json!({ "shard": 2, "warm": true }),
861        );
862
863        let json = serde_json::to_string(&t).unwrap();
864        let back: Transcript = serde_json::from_str(&json).unwrap();
865        assert_eq!(back.metadata["attempt"], serde_json::json!(3));
866        assert_eq!(back.metadata["ctx"]["shard"], serde_json::json!(2));
867
868        // Display: strings render bare; structured values render as compact JSON.
869        assert_eq!(metadata_display(&back.metadata["trace"]), "https://obs/123");
870        assert_eq!(metadata_display(&back.metadata["attempt"]), "3");
871        assert_eq!(
872            metadata_display(&back.metadata["ctx"]),
873            r#"{"shard":2,"warm":true}"#
874        );
875    }
876}