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}