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}