use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use clap::{Args, CommandFactory, Parser, Subcommand};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use tokio::process::Command;
mod config;
mod env;
use mira::Host;
use mira::Trial;
use mira::exec::{self, CellSpec, Concurrency};
use mira::protocol::{
ExecuteResult, InitializeResult, ListResult, RunResult, TranscriptSummary, capabilities,
};
use mira::report::{self, Format};
use mira::run::{RUN_META_FORMAT, RunMeta, RunSummary, new_run_id_at};
use mira::session::{self, Session, now_unix};
const REPO_URL: &str = "https://github.com/everruns/mira";
const ISSUES_URL: &str = "https://github.com/everruns/mira/issues";
const DOCS_URL: &str = "https://github.com/everruns/mira/tree/main/docs";
const API_DOCS_URL: &str = "https://docs.rs/mira-eval";
const ABOUT: &str = "Run code-first evals for agents and tools across a target matrix — \
the Mira host CLI.";
const HELP_HINT: &str = "Tip: run `mira help --full` for an overview, every flag, examples, \
and links (repo, issues, docs).";
#[derive(Parser)]
#[command(
name = "mira",
version,
about = ABOUT,
after_help = HELP_HINT,
disable_help_subcommand = true,
)]
struct Cli {
#[command(flatten)]
launcher: Launcher,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Args)]
struct Launcher {
#[arg(long, global = true)]
bin: Option<String>,
#[arg(long, global = true)]
example: Option<String>,
#[arg(long, global = true)]
cmd: Option<String>,
#[arg(long, global = true)]
package: Option<String>,
#[arg(long, global = true)]
manifest_path: Option<String>,
}
#[derive(Subcommand)]
enum Cmd {
List,
Run(RunArgs),
Score(ScoreArgs),
Help(HelpArgs),
}
#[derive(Args)]
struct HelpArgs {
#[arg(long)]
full: bool,
}
#[derive(Args)]
struct RunArgs {
filter: Option<String>,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
targets: Option<String>,
#[arg(long = "axis", value_name = "NAME=V1,V2")]
axes: Vec<String>,
#[arg(long)]
preset: Option<String>,
#[arg(long)]
trials: Option<usize>,
#[arg(long)]
seed: Option<u64>,
#[arg(long)]
group_by: Option<String>,
#[arg(long)]
out: Option<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "DIR")]
save: Option<String>,
#[arg(long, default_value = "json")]
format: String,
#[arg(long)]
checkpoint: Option<String>,
#[arg(long)]
fresh: bool,
#[arg(long, short = 'j', default_value_t = 8)]
max_concurrent: usize,
#[arg(long)]
provider_concurrency: Option<String>,
#[arg(long)]
no_adaptive: bool,
#[arg(long, default_value_t = 4)]
max_retries: u32,
#[arg(long, requires = "artifacts", conflicts_with_all = ["checkpoint", "out", "save"])]
execute_only: bool,
#[arg(long)]
artifacts: Option<String>,
}
#[derive(Args)]
struct ScoreArgs {
filter: Option<String>,
#[arg(long)]
artifacts: String,
#[arg(long)]
group_by: Option<String>,
#[arg(long)]
out: Option<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "DIR")]
save: Option<String>,
#[arg(long, default_value = "json")]
format: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match &cli.cmd {
None => {
Cli::command().print_help()?;
return Ok(());
}
Some(Cmd::Help(args)) => {
if args.full {
print_full_help()?;
} else {
Cli::command().print_help()?;
}
return Ok(());
}
_ => {}
}
let progress = Arc::new(ProgressBar::hidden());
let progress_evt = progress.clone();
let host = Host::spawn(build_command(&cli.launcher))
.await?
.on_event(move |n| {
if let Some(log) = n.as_log() {
progress_evt.suspend(|| eprintln!(" study: {}", log.message));
}
});
let info = host.initialize("mira-cli").await?;
eprintln!(
"study {} · protocol {} · {} evals",
info.study, info.protocol_version, info.evals
);
let listing = host.list_complete().await?;
match cli.cmd {
Some(Cmd::List) => {
print_listing(&listing);
host.shutdown().await?;
Ok(())
}
Some(Cmd::Run(args)) => run(host, info, listing, args, progress).await,
Some(Cmd::Score(args)) => score(host, info, listing, args).await,
None | Some(Cmd::Help(_)) => unreachable!("handled before host spawn"),
}
}
fn print_full_help() -> std::io::Result<()> {
use std::io::Write;
let overview = "\
OVERVIEW
Mira is a Rust-first, code-first evaluation framework for agents and tools —
built for multi-turn, tool-using, long-running trajectories.
You write evals as code (in Rust, or any language that speaks the protocol);
this binary is the HOST. It owns the run end to end: it launches your eval
program (the `study`), enumerates what it advertises, plans the grid
(selection x target matrix x axes), executes each cell over the protocol,
scores the results, then aggregates, reports, and checkpoints. Execution and
scoring can be split for long runs (`run --execute-only` then `score`).
Point it at any study: `--bin NAME`, `--example NAME`, an arbitrary
`--cmd \"...\"` (e.g. a Python study), or `--package` / `--manifest-path`.";
let examples = "\
EXAMPLES
mira --bin greet list # what the study advertises
mira --bin greet run # run the whole matrix
mira --bin greet run greet # selective (substring), like cargo test
mira --bin greet run --tag smoke # only samples carrying a tag
mira --bin greet run --targets sim --format junit --out results.xml
mira --bin greet run --format html --out report.html # self-contained viewer
mira --bin greet run --checkpoint ck.json # resumable long runs
mira --bin greet run --save # archive run under ./results/<run_id>/
mira --bin greet run --execute-only --artifacts art/ # capture transcripts
mira --bin greet score --artifacts art/ # score (or re-score) them
mira --cmd \"python study.py\" run # drive a non-Rust (polyglot) study";
let links = format!(
"\
LINKS
Repository: {REPO_URL}
Issues: {ISSUES_URL}
Docs: {DOCS_URL}
API docs: {API_DOCS_URL}"
);
let flags = Cli::command()
.about(None)
.after_help(None)
.render_long_help();
let mut out = std::io::stdout().lock();
writeln!(out, "{ABOUT}\n")?;
writeln!(out, "{overview}\n")?;
write!(out, "{flags}")?;
writeln!(out, "\n{examples}\n")?;
writeln!(out, "{links}")?;
Ok(())
}
fn build_command(launcher: &Launcher) -> Command {
if let Some(raw) = &launcher.cmd {
let mut parts = raw.split_whitespace();
let program = parts.next().unwrap_or("false");
let mut command = Command::new(program);
command.args(parts);
return command;
}
let mut command = Command::new("cargo");
command.arg("run").arg("-q");
if let Some(pkg) = &launcher.package {
command.arg("-p").arg(pkg);
}
if let Some(bin) = &launcher.bin {
command.arg("--bin").arg(bin);
} else if let Some(example) = &launcher.example {
command.arg("--example").arg(example);
} else {
command.arg("--bin").arg("greet");
}
if let Some(manifest) = &launcher.manifest_path {
command.arg("--manifest-path").arg(manifest);
}
command
}
async fn run(
host: Host,
info: InitializeResult,
listing: ListResult,
args: RunArgs,
progress: Arc<ProgressBar>,
) -> Result<(), Box<dyn std::error::Error>> {
let format = Format::from_str(&args.format)?;
let started_unix = now_unix();
let run_id = new_run_id_at(started_unix);
let save_dir = config::resolve_save_dir(&args.save);
let selection = resolve_selection(&args).map_err(Box::<dyn std::error::Error>::from)?;
validate_selection(&selection, &listing).map_err(Box::<dyn std::error::Error>::from)?;
let plan = plan_grid(&listing, &args, &selection);
if plan.is_empty() {
eprintln!("no cells matched the selection");
}
if args.execute_only {
require_capability(&info, capabilities::EXECUTE, "--execute-only")?;
let dir = args.artifacts.as_ref().expect("clap requires artifacts");
return execute_only(host, &plan, dir, args.fresh).await;
}
let fingerprints = session::fingerprints(&listing);
let mut done: BTreeMap<String, RunResult> = BTreeMap::new();
let mut session = Session::new(
info.study.clone(),
info.study_version.clone(),
plan.len(),
fingerprints.clone(),
);
if let Some(path) = &args.checkpoint
&& !args.fresh
{
match Session::load(path) {
Ok(Some(prev)) => {
let stale = prev.stale_keys(&fingerprints);
for r in prev.results {
done.insert(r.key(), r);
}
session.created_unix = prev.created_unix;
let resumable = plan.iter().filter(|c| done.contains_key(&c.key())).count();
eprintln!(
"resuming checkpoint: {resumable}/{} cells already done",
plan.len()
);
if !stale.is_empty() {
eprintln!(
"warning: {} cached cell(s) are stale — their eval definition changed \
since they were recorded. They'll be reused as-is; re-run with --fresh \
to recompute. e.g. {}",
stale.len(),
stale.iter().take(3).cloned().collect::<Vec<_>>().join(", "),
);
}
}
Ok(None) => {}
Err(e) => eprintln!("warning: ignoring checkpoint {path}: {e}; starting fresh"),
}
}
let resumable = plan.iter().filter(|c| done.contains_key(&c.key())).count();
if !plan.is_empty() && std::io::stderr().is_terminal() {
progress.set_draw_target(ProgressDrawTarget::stderr());
progress.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{elapsed_precise}] [{bar:30.cyan/blue}] \
{pos}/{len} (eta {eta}) {msg}",
)
.unwrap()
.progress_chars("=>-"),
);
}
progress.set_length(plan.len() as u64);
progress.set_position(resumable as u64);
let todo: Vec<CellSpec> = plan
.iter()
.filter(|cell| !done.contains_key(&cell.key()))
.cloned()
.collect();
let cfg = concurrency(&args);
{
let handle = host.handle();
exec::run_cells(
todo,
&cfg,
|cell| {
let handle = handle.clone();
async move {
handle
.run(
&cell.eval,
&cell.sample,
&cell.target,
&cell.params,
cell.trial,
)
.await
}
},
|cell, result| {
progress.set_message(cell.key());
done.insert(cell.key(), result);
progress.inc(1);
if let Some(path) = &args.checkpoint {
let results = plan
.iter()
.filter_map(|c| done.get(&c.key()).cloned())
.collect();
session.update(plan.len(), fingerprints.clone(), results);
if let Err(e) = session.save(path) {
progress.suspend(|| eprintln!("warning: failed to write checkpoint: {e}"));
}
}
},
)
.await;
}
progress.finish_and_clear();
host.shutdown().await?;
let results: Vec<RunResult> = plan
.iter()
.filter_map(|cell| done.get(&cell.key()).cloned())
.collect();
report::print_results(&results);
let group_vals = args
.group_by
.as_deref()
.map(|key| group_values(&results, key, &listing));
let group = match (args.group_by.as_deref(), group_vals.as_deref()) {
(Some(key), Some(values)) => {
report::print_group_breakdown(&results, key, values);
Some(report::Group { key, values })
}
_ => None,
};
if let Some(path) = &args.out {
std::fs::write(path, report::render_with_group(&results, format, group))?;
eprintln!("\nwrote {path} ({:?})", format);
}
if let Some(base) = &save_dir {
save_results(base, &run_id, &info, started_unix, &results, group)?;
}
let failed = results
.iter()
.any(|r| !r.skipped && !report::is_na(r) && !r.passed);
std::process::exit(if failed { 1 } else { 0 });
}
fn save_results(
base: &str,
run_id: &str,
info: &InitializeResult,
started_unix: u64,
results: &[RunResult],
group: Option<report::Group<'_>>,
) -> Result<(), Box<dyn std::error::Error>> {
let cfg = config::Config::load();
let environment = cfg
.environment
.enabled
.then(|| env::collect(&cfg.environment.labels))
.flatten();
let meta = RunMeta {
format: RUN_META_FORMAT,
run_id: run_id.to_string(),
study: info.study.clone(),
study_version: info.study_version.clone(),
started_unix,
finished_unix: now_unix(),
environment,
summary: RunSummary::of(results),
};
let dir = config::save_run(base, &meta, results, group)?;
eprintln!("\nsaved run {} to {}", meta.run_id, dir.display());
Ok(())
}
fn concurrency(args: &RunArgs) -> Concurrency {
let mut cfg = Concurrency::new(args.max_concurrent);
cfg.adaptive = !args.no_adaptive;
cfg.max_retries = args.max_retries;
if let Some(spec) = &args.provider_concurrency {
for entry in spec.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if let Some((provider, n)) = entry.split_once('=')
&& let Ok(limit) = n.trim().parse::<usize>()
{
cfg = cfg.provider(provider.trim(), limit);
} else {
eprintln!("ignoring malformed --provider-concurrency entry: {entry:?}");
}
}
}
cfg
}
fn require_capability(
info: &InitializeResult,
cap: &str,
feature: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if info.capabilities.iter().any(|c| c == cap) {
return Ok(());
}
Err(format!(
"study {} doesn't support {feature}: it doesn't advertise the `{cap}` capability",
info.study
)
.into())
}
async fn execute_only(
host: Host,
plan: &[CellSpec],
dir: &str,
fresh: bool,
) -> Result<(), Box<dyn std::error::Error>> {
std::fs::create_dir_all(dir)?;
let mut wrote = 0usize;
for cell in plan {
let path = artifact_path(dir, &cell.key());
if !fresh && path.exists() {
continue;
}
let result = host
.execute(
&cell.eval,
&cell.sample,
&cell.target,
&cell.params,
cell.trial,
)
.await?;
std::fs::write(&path, serde_json::to_string_pretty(&result)?)?;
wrote += 1;
}
host.shutdown().await?;
eprintln!("executed {wrote} cell(s); artifacts in {dir}");
eprintln!("score them with: mira score --artifacts {dir}");
Ok(())
}
async fn score(
host: Host,
info: InitializeResult,
listing: ListResult,
args: ScoreArgs,
) -> Result<(), Box<dyn std::error::Error>> {
require_capability(&info, capabilities::SCORE, "mira score")?;
let format = Format::from_str(&args.format)?;
let started_unix = now_unix();
let run_id = new_run_id_at(started_unix);
let save_dir = config::resolve_save_dir(&args.save);
let mut artifacts = load_artifacts(&args.artifacts);
if let Some(f) = &args.filter {
artifacts.retain(|a| a.key().contains(f.as_str()));
}
if artifacts.is_empty() {
eprintln!("no artifacts in {}", args.artifacts);
}
let mut results = Vec::with_capacity(artifacts.len());
for artifact in &artifacts {
if artifact.skipped {
results.push(skipped_result(artifact));
} else {
results.push(host.score(artifact).await?);
}
}
host.shutdown().await?;
report::print_results(&results);
let group_vals = args
.group_by
.as_deref()
.map(|key| group_values(&results, key, &listing));
let group = match (args.group_by.as_deref(), group_vals.as_deref()) {
(Some(key), Some(values)) => {
report::print_group_breakdown(&results, key, values);
Some(report::Group { key, values })
}
_ => None,
};
if let Some(path) = &args.out {
std::fs::write(path, report::render_with_group(&results, format, group))?;
eprintln!("\nwrote {path} ({:?})", format);
}
if let Some(base) = &save_dir {
save_results(base, &run_id, &info, started_unix, &results, group)?;
}
let failed = results
.iter()
.any(|r| !r.skipped && !report::is_na(r) && !r.passed);
std::process::exit(if failed { 1 } else { 0 });
}
fn skipped_result(a: &ExecuteResult) -> RunResult {
RunResult {
eval: a.eval.clone(),
sample: a.sample.clone(),
target: a.target.clone(),
params: a.params.clone(),
trial: a.trial,
trials: a.trials,
seed: a.seed,
passed: false,
aggregate: 0.0,
scores: Vec::new(),
transcript: TranscriptSummary::of(&a.transcript),
skipped: true,
}
}
fn artifact_path(dir: &str, key: &str) -> std::path::PathBuf {
let mut safe = String::with_capacity(key.len());
for b in key.bytes() {
if b.is_ascii_alphanumeric() {
safe.push(b as char);
} else {
safe.push('_');
safe.push_str(&format!("{b:02x}"));
}
}
Path::new(dir).join(format!("{safe}.json"))
}
fn load_artifacts(dir: &str) -> Vec<ExecuteResult> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
eprintln!("warning: cannot read artifacts dir {dir}: {e}");
return out;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<ExecuteResult>(&text) {
Ok(result) => out.push(result),
Err(e) => {
eprintln!(
"warning: skipping {}: invalid artifact JSON: {e}",
path.display()
)
}
},
Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
}
}
out.sort_by_key(|a| a.key());
out
}
fn axis_combinations(eval: &mira::protocol::EvalInfo) -> Vec<BTreeMap<String, String>> {
let mut combos = vec![BTreeMap::new()];
for axis in &eval.axes {
let mut next = Vec::new();
for combo in &combos {
for value in &axis.values {
let mut c = combo.clone();
c.insert(axis.name.clone(), value.clone());
next.push(c);
}
}
if !next.is_empty() {
combos = next;
}
}
combos
}
struct Selection {
filter: Option<String>,
tag: Option<String>,
evals: Option<Vec<String>>,
axes: BTreeMap<String, Vec<String>>,
}
fn split_csv(s: &str) -> Vec<String> {
s.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect()
}
fn resolve_selection(args: &RunArgs) -> Result<Selection, String> {
let preset = match &args.preset {
Some(name) => config::Config::load().preset(name)?,
None => config::Preset::default(),
};
let filter = args.filter.clone().or(preset.filter);
let tag = args.tag.clone().or(preset.tag);
let evals = (!preset.evals.is_empty()).then_some(preset.evals.clone());
let mut axes: BTreeMap<String, Vec<String>> = preset.axes.clone();
let targets = match &args.targets {
Some(s) => split_csv(s),
None => preset.targets.clone(),
};
if !targets.is_empty() {
axes.insert("target".to_string(), targets);
}
for spec in &args.axes {
let (name, vals) = spec
.split_once('=')
.ok_or_else(|| format!("--axis expects NAME=V1,V2, got {spec:?}"))?;
let values = split_csv(vals);
if values.is_empty() {
return Err(format!("--axis {name}= lists no values"));
}
axes.insert(name.trim().to_string(), values);
}
Ok(Selection {
filter,
tag,
evals,
axes,
})
}
fn validate_selection(sel: &Selection, listing: &ListResult) -> Result<(), String> {
use std::collections::BTreeSet;
let mut declared: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for eval in &listing.evals {
let t = declared.entry("target".to_string()).or_default();
for m in &eval.targets {
t.insert(m.label.clone());
}
for a in &eval.axes {
let e = declared.entry(a.name.clone()).or_default();
for v in &a.values {
e.insert(v.clone());
}
}
}
let listed = |set: &BTreeSet<String>| {
let mut v: Vec<&str> = set.iter().map(String::as_str).collect();
v.sort_unstable();
v.join(", ")
};
for (name, allowed) in &sel.axes {
let Some(valid) = declared.get(name) else {
let names: BTreeSet<String> = declared.keys().cloned().collect();
return Err(format!(
"unknown axis {name:?} (declared: {})",
listed(&names)
));
};
for v in allowed {
if !valid.contains(v) {
return Err(format!(
"axis {name:?} has no value {v:?} (declared: {})",
listed(valid)
));
}
}
}
if let Some(evals) = &sel.evals {
let known: BTreeSet<String> = listing.evals.iter().map(|e| e.name.clone()).collect();
for e in evals {
if !known.contains(e) {
return Err(format!("unknown eval {e:?} (declared: {})", listed(&known)));
}
}
}
Ok(())
}
fn axes_allowed(sel: &Selection, params: &mira::Params) -> bool {
params.iter().all(|(name, value)| {
sel.axes
.get(name)
.is_none_or(|allowed| allowed.contains(value))
})
}
fn plan_grid(listing: &ListResult, args: &RunArgs, sel: &Selection) -> Vec<CellSpec> {
let mut plan = Vec::new();
for eval in &listing.evals {
if let Some(evals) = &sel.evals
&& !evals.iter().any(|e| e == &eval.name)
{
continue;
}
let combos = axis_combinations(eval);
let trials = args.trials.unwrap_or(eval.trials).max(1);
let seed_base = args.seed.or(eval.seed);
for sample in &eval.samples {
if let Some(tag) = &sel.tag
&& !sample.tags.contains(tag)
{
continue;
}
for target in &eval.targets {
if let Some(allow) = sel.axes.get("target")
&& !allow.contains(&target.label)
{
continue;
}
for params in &combos {
if !axes_allowed(sel, params) {
continue;
}
let key = mira::cell_key(&eval.name, &sample.id, &target.label, params);
if let Some(f) = &sel.filter
&& !key.contains(f.as_str())
{
continue;
}
for index in 0..trials {
plan.push(CellSpec {
eval: eval.name.clone(),
sample: sample.id.clone(),
target: target.label.clone(),
provider: target.provider.clone(),
params: params.clone(),
trial: Trial {
index,
count: trials,
seed: seed_base.map(|s| s.wrapping_add(index as u64)),
},
});
}
}
}
}
}
plan
}
type MetaIndex = BTreeMap<(String, String), mira::Metadata>;
fn meta_indexes(listing: &ListResult) -> (MetaIndex, MetaIndex) {
let mut sample_meta = MetaIndex::new();
let mut model_meta = MetaIndex::new();
for eval in &listing.evals {
for s in &eval.samples {
if !s.metadata.is_empty() {
sample_meta.insert((eval.name.clone(), s.id.clone()), s.metadata.clone());
}
}
for m in &eval.targets {
if !m.metadata.is_empty() {
model_meta.insert((eval.name.clone(), m.label.clone()), m.metadata.clone());
}
}
}
(sample_meta, model_meta)
}
fn group_value(
r: &RunResult,
key: &str,
sample_meta: &MetaIndex,
model_meta: &MetaIndex,
) -> Option<String> {
if let Some(v) = r.params.get(key) {
return Some(v.clone());
}
if let Some(v) = sample_meta
.get(&(r.eval.clone(), r.sample.clone()))
.and_then(|m| m.get(key))
{
return Some(mira::metadata_display(v));
}
if let Some(v) = model_meta
.get(&(r.eval.clone(), r.target.clone()))
.and_then(|m| m.get(key))
{
return Some(mira::metadata_display(v));
}
r.transcript.metadata.get(key).map(mira::metadata_display)
}
fn group_values(results: &[RunResult], key: &str, listing: &ListResult) -> Vec<Option<String>> {
let (sample_meta, model_meta) = meta_indexes(listing);
results
.iter()
.map(|r| group_value(r, key, &sample_meta, &model_meta))
.collect()
}
fn print_listing(listing: &ListResult) {
for eval in &listing.evals {
let desc = if eval.description.is_empty() {
String::new()
} else {
format!(" — {}", eval.description)
};
let trials = if eval.trials > 1 {
let seed = eval.seed.map(|s| format!(", seed={s}")).unwrap_or_default();
format!(", trials={}{seed}", eval.trials)
} else {
String::new()
};
println!(
"{}{desc} (max_turns={}{trials})",
eval.name, eval.max_turns
);
println!(
" samples: {}",
eval.samples
.iter()
.map(|s| {
let mut label = s.id.clone();
if !s.tags.is_empty() {
label.push_str(&format!(" [{}]", s.tags.join(",")));
}
if !s.metadata.is_empty() {
label.push_str(&format!(" {{{}}}", fmt_meta(&s.metadata)));
}
label
})
.collect::<Vec<_>>()
.join(", ")
);
println!(" scorers: {}", eval.scorers.join(", "));
println!(
" targets: {}",
eval.targets
.iter()
.map(|m| {
let mut label = m.label.clone();
if !m.available {
label.push_str(" (unavailable)");
}
if !m.metadata.is_empty() {
label.push_str(&format!(" {{{}}}", fmt_meta(&m.metadata)));
}
label
})
.collect::<Vec<_>>()
.join(", ")
);
if !eval.axes.is_empty() {
let axes: Vec<String> = eval
.axes
.iter()
.map(|a| format!("{}=[{}]", a.name, a.values.join(",")))
.collect();
println!(" axes: {}", axes.join(", "));
}
if !eval.metadata.is_empty() {
println!(" meta: {}", fmt_meta(&eval.metadata));
}
}
}
fn fmt_meta(meta: &mira::Metadata) -> String {
meta.iter()
.map(|(k, v)| format!("{k}={}", mira::metadata_display(v)))
.collect::<Vec<_>>()
.join(", ")
}