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