#![allow(clippy::type_complexity)]
#![forbid(unsafe_code)]
pub mod aggregate;
pub mod content;
pub mod dataset;
pub mod eval;
pub mod exec;
pub mod glob;
pub mod host;
pub mod protocol;
pub mod registry;
pub mod report;
pub mod run;
pub mod runner;
pub mod scorer;
pub mod study;
pub mod subject;
pub mod target;
pub mod trajectory;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[doc(hidden)]
pub use inventory;
#[cfg(feature = "macros")]
pub use mira_macros::eval;
pub use aggregate::{TrialAggregate, aggregate_trials};
pub use content::{Message, Part, Role, Source};
pub use dataset::{Dataset, Sample};
pub use eval::Eval;
pub use exec::{Concurrency, run_cases};
pub use glob::glob_match;
pub use host::{Host, HostHandle};
pub use target::Target;
pub use registry::registered_evals;
pub use run::{RunMeta, RunSummary, new_run_id, new_run_id_at, now_unix};
pub use runner::{CaseOutcome, RunReport, Runner};
pub use scorer::Scorer;
pub use study::Study;
pub use subject::{CliSubject, Subject, subject_fn};
pub use trajectory::{ToolInvocation, Trajectory};
pub type Metadata = BTreeMap<String, serde_json::Value>;
pub type Params = BTreeMap<String, String>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Trial {
pub index: usize,
pub count: usize,
pub seed: Option<u64>,
}
impl Default for Trial {
fn default() -> Self {
Self::single()
}
}
impl Trial {
pub fn single() -> Self {
Self {
index: 0,
count: 1,
seed: None,
}
}
pub fn is_repeated(&self) -> bool {
self.count > 1
}
pub fn key_suffix(&self) -> String {
trial_suffix(self.index, self.count)
}
}
pub fn trial_suffix(trial: usize, trials: usize) -> String {
if trials > 1 {
format!("#{trial}")
} else {
String::new()
}
}
pub fn metadata_display(value: &serde_json::Value) -> String {
match value.as_str() {
Some(s) => s.to_string(),
None => value.to_string(),
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub cache_read_tokens: u64,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub reasoning_tokens: u64,
pub cost_usd: f64,
}
fn is_zero_u64(v: &u64) -> bool {
*v == 0
}
impl Usage {
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens
}
pub fn add(&mut self, other: &Usage) {
self.input_tokens += other.input_tokens;
self.output_tokens += other.output_tokens;
self.cache_read_tokens += other.cache_read_tokens;
self.reasoning_tokens += other.reasoning_tokens;
self.cost_usd += other.cost_usd;
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Timing {
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_to_first_token_ms: Option<u64>,
}
impl Timing {
pub fn is_default(&self) -> bool {
*self == Timing::default()
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Transcript {
#[serde(default)]
pub final_response: String,
#[serde(default)]
pub iterations: usize,
#[serde(default)]
pub tool_calls_count: usize,
#[serde(default)]
pub usage: Usage,
#[serde(default, skip_serializing_if = "Timing::is_default")]
pub timing: Timing,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub files: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trajectory: Option<trajectory::Trajectory>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metrics: BTreeMap<String, f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output: Vec<Part>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: Metadata,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "ErrorKind::is_subject")]
pub error_kind: ErrorKind,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
#[default]
Subject,
Infra,
}
impl ErrorKind {
pub fn is_subject(&self) -> bool {
matches!(self, ErrorKind::Subject)
}
}
impl Transcript {
pub fn response(text: impl Into<String>) -> Self {
Self {
final_response: text.into(),
..Default::default()
}
}
pub fn failed(error: impl Into<String>) -> Self {
Self {
error: Some(error.into()),
..Default::default()
}
}
pub fn infra_error(error: impl Into<String>) -> Self {
Self {
error: Some(error.into()),
error_kind: ErrorKind::Infra,
..Default::default()
}
}
pub fn from_trajectory(trajectory: trajectory::Trajectory) -> Self {
let mut t = Self::default();
trajectory.project_into(&mut t);
t.trajectory = Some(trajectory);
t
}
pub fn project_trajectory(&mut self) {
if let Some(trajectory) = self.trajectory.take() {
trajectory.project_into(self);
self.trajectory = Some(trajectory);
}
}
pub fn succeeded(&self) -> bool {
self.error.is_none()
}
pub fn errored_infra(&self) -> bool {
self.error.is_some() && self.error_kind == ErrorKind::Infra
}
pub fn tools_used(&self) -> Vec<String> {
let mut seen = Vec::new();
for name in &self.tool_calls {
if !seen.contains(name) {
seen.push(name.clone());
}
}
seen
}
pub fn with_duration_ms(mut self, ms: u64) -> Self {
self.timing.duration_ms = ms;
self
}
pub fn with_metric(mut self, name: impl Into<String>, value: f64) -> Self {
self.record_metric(name, value);
self
}
pub fn record_metric(&mut self, name: impl Into<String>, value: f64) {
if value.is_finite() {
self.metrics.insert(name.into(), value);
}
}
pub fn metric(&self, name: &str) -> Option<f64> {
self.metrics.get(name).copied()
}
pub fn with_output(mut self, parts: impl IntoIterator<Item = Part>) -> Self {
self.output = parts.into_iter().collect();
self
}
pub fn output_modalities(&self) -> Vec<&'static str> {
content::modalities(&self.output)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Score {
pub scorer: String,
pub value: f64,
pub pass: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub na: bool,
pub reason: String,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl Score {
pub fn pass(scorer: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
scorer: scorer.into(),
value: 1.0,
pass: true,
na: false,
reason: reason.into(),
}
}
pub fn fail(scorer: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
scorer: scorer.into(),
value: 0.0,
pass: false,
na: false,
reason: reason.into(),
}
}
pub fn na(scorer: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
scorer: scorer.into(),
value: 0.0,
pass: false,
na: true,
reason: reason.into(),
}
}
pub fn graded(
scorer: impl Into<String>,
value: f64,
threshold: f64,
reason: impl Into<String>,
) -> Self {
let value = value.clamp(0.0, 1.0);
Self {
scorer: scorer.into(),
value,
pass: value >= threshold,
na: false,
reason: reason.into(),
}
}
pub fn is_na(&self) -> bool {
self.na
}
}
#[derive(Clone, Debug)]
pub struct RunCx {
pub target: Target,
pub max_turns: usize,
pub params: Params,
pub trial: Trial,
pub conversation: Vec<Message>,
}
impl RunCx {
pub fn new(target: Target) -> Self {
Self {
target,
max_turns: 12,
params: Params::new(),
trial: Trial::single(),
conversation: Vec::new(),
}
}
pub fn param(&self, name: &str) -> Option<&str> {
self.params.get(name).map(String::as_str)
}
pub fn seed(&self) -> Option<u64> {
self.trial.seed
}
}
pub fn case_key(eval: &str, sample: &str, target: &str, params: &Params) -> String {
let base = format!("{eval}/{sample}@{target}");
if params.is_empty() {
return base;
}
let suffix = params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",");
format!("{base}[{suffix}]")
}
pub fn is_rate_limited(message: &str) -> bool {
let m = message.to_ascii_lowercase();
m.contains("429")
|| m.contains("rate limit")
|| m.contains("rate-limit")
|| m.contains("ratelimit")
|| m.contains("too many requests")
|| m.contains("overloaded")
|| m.contains("quota")
|| m.contains("try again later")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usage_accumulates() {
let mut a = Usage {
input_tokens: 10,
output_tokens: 5,
cost_usd: 0.1,
..Default::default()
};
a.add(&Usage {
input_tokens: 1,
output_tokens: 2,
reasoning_tokens: 4,
cost_usd: 0.01,
..Default::default()
});
assert_eq!(a.input_tokens, 11);
assert_eq!(a.total_tokens(), 18);
assert_eq!(a.reasoning_tokens, 4);
assert!((a.cost_usd - 0.11).abs() < 1e-9);
}
#[test]
fn score_graded_respects_threshold() {
let s = Score::graded("s", 0.8, 0.7, "ok");
assert!(s.pass);
let s = Score::graded("s", 0.6, 0.7, "low");
assert!(!s.pass);
assert_eq!(Score::graded("s", 2.0, 0.7, "").value, 1.0);
}
#[test]
fn na_score_is_neither_pass_nor_fail() {
let s = Score::na("judge", "model unreachable");
assert!(s.is_na());
assert!(!s.pass);
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"na\":true"));
let p = Score::pass("s", "ok");
assert!(!serde_json::to_string(&p).unwrap().contains("na"));
}
#[test]
fn transcript_helpers() {
assert!(Transcript::response("hi").succeeded());
assert!(!Transcript::failed("boom").succeeded());
}
#[test]
fn infra_error_is_distinct_from_subject_error() {
let infra = Transcript::infra_error("budget exhausted");
assert!(!infra.succeeded());
assert!(infra.errored_infra());
assert_eq!(infra.error_kind, ErrorKind::Infra);
let subject = Transcript::failed("wrong answer");
assert!(!subject.succeeded());
assert!(!subject.errored_infra()); assert_eq!(subject.error_kind, ErrorKind::Subject);
assert!(!Transcript::response("ok").errored_infra());
let subj = serde_json::to_string(&Transcript::failed("x")).unwrap();
assert!(!subj.contains("error_kind"));
let inf = serde_json::to_string(&Transcript::infra_error("x")).unwrap();
assert!(inf.contains("\"error_kind\":\"infra\""));
}
#[test]
fn detects_rate_limit_signals() {
assert!(is_rate_limited("HTTP 429 Too Many Requests"));
assert!(is_rate_limited("anthropic: overloaded_error"));
assert!(is_rate_limited("Rate limit exceeded, try again later"));
assert!(is_rate_limited("insufficient_quota"));
assert!(!is_rate_limited("invalid api key"));
assert!(!is_rate_limited("connection refused"));
}
#[test]
fn custom_metrics_round_trip_and_reject_non_finite() {
let t = Transcript::response("ok")
.with_metric("recall@5", 0.8)
.with_metric("nan", f64::NAN)
.with_metric("inf", f64::INFINITY);
assert_eq!(t.metric("recall@5"), Some(0.8));
assert_eq!(t.metric("nan"), None);
assert_eq!(t.metric("inf"), None);
serde_json::to_string(&t).expect("transcript with metrics serializes");
}
#[test]
fn multimodal_output_rides_alongside_text() {
let t = Transcript::response("a cat on a mat").with_output([
Part::text("a cat on a mat"),
Part::image_uri("image/png", "https://x/cat.png"),
]);
assert_eq!(t.final_response, "a cat on a mat");
assert_eq!(t.output_modalities(), vec!["text", "image"]);
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains(r#""kind":"image""#));
let back: Transcript = serde_json::from_str(&json).unwrap();
assert_eq!(back.output, t.output);
}
#[test]
fn trajectory_only_transcript_round_trips_the_wire_and_projects() {
use crate::trajectory::{Agent, Step, StepSource, ToolCall, Trajectory};
let mut trajectory = Trajectory::new(Agent::new("test-agent", "1.0"));
let mut step = Step::new(1, StepSource::Agent, "the answer is 42");
step.tool_calls = vec![ToolCall::new(
"c1",
"calc",
serde_json::json!({"expr": "6*7"}),
)];
trajectory.steps.push(step);
let wire = serde_json::to_string(&serde_json::json!({ "trajectory": trajectory })).unwrap();
let mut t: Transcript = serde_json::from_str(&wire).unwrap();
assert!(t.final_response.is_empty()); t.project_trajectory();
assert_eq!(t.final_response, "the answer is 42");
assert_eq!(t.tool_calls, vec!["calc"]);
assert_eq!(t.tool_calls_count, 1);
assert_eq!(t.iterations, 1);
assert!(t.events.is_empty());
let again: Transcript = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
assert_eq!(again.trajectory, t.trajectory);
let mut twice = again.clone();
twice.project_trajectory();
assert_eq!(twice.tool_calls, again.tool_calls);
let plain = serde_json::to_string(&Transcript::response("ok")).unwrap();
assert!(!plain.contains("trajectory"));
}
#[test]
fn metadata_is_open_ended_and_round_trips() {
let mut t = Transcript::response("ok");
t.metadata.insert("trace".into(), "https://obs/123".into());
t.metadata.insert("attempt".into(), 3.into());
t.metadata.insert(
"ctx".into(),
serde_json::json!({ "shard": 2, "warm": true }),
);
let json = serde_json::to_string(&t).unwrap();
let back: Transcript = serde_json::from_str(&json).unwrap();
assert_eq!(back.metadata["attempt"], serde_json::json!(3));
assert_eq!(back.metadata["ctx"]["shard"], serde_json::json!(2));
assert_eq!(metadata_display(&back.metadata["trace"]), "https://obs/123");
assert_eq!(metadata_display(&back.metadata["attempt"]), "3");
assert_eq!(
metadata_display(&back.metadata["ctx"]),
r#"{"shard":2,"warm":true}"#
);
}
}