use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::protocol::RunResult;
pub const RUN_META_FORMAT: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunMeta {
pub format: u32,
pub run_id: String,
pub study: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub study_version: Option<String>,
pub started_unix: u64,
pub finished_unix: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<Environment>,
pub summary: RunSummary,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Environment {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpus: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_total_mib: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mira_version: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
}
impl Environment {
pub fn is_empty(&self) -> bool {
*self == Environment::default()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct GitInfo {
pub commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
pub dirty: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct RunSummary {
pub scored: usize,
pub passed: usize,
pub failed: usize,
pub na: usize,
pub skipped: usize,
pub total_tokens: u64,
pub total_cost_usd: f64,
pub total_tool_calls: usize,
pub total_duration_ms: u64,
}
impl RunSummary {
pub fn of(results: &[RunResult]) -> Self {
let mut s = RunSummary::default();
for r in results {
if r.skipped {
s.skipped += 1;
continue;
}
if crate::report::is_na(r) {
s.na += 1;
} else {
s.scored += 1;
if r.passed {
s.passed += 1;
}
}
s.total_tokens += r.transcript.usage.total_tokens();
s.total_cost_usd += r.transcript.usage.cost_usd;
s.total_tool_calls += r.transcript.tool_calls_count;
s.total_duration_ms += r.transcript.timing.duration_ms;
}
s.failed = s.scored - s.passed;
s
}
}
pub fn new_run_id_at(started_unix: u64) -> String {
format!(
"{}-{}",
format_compact_utc(started_unix),
Hex4(short_suffix())
)
}
pub fn new_run_id() -> String {
new_run_id_at(now_unix())
}
pub fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn format_compact_utc(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let (y, m, d) = civil_from_days(days);
format!("{y:04}{m:02}{d:02}T{h:02}{mi:02}{s:02}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
fn short_suffix() -> u16 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let pid = std::process::id();
((nanos ^ pid.wrapping_mul(2_654_435_761)) & 0xffff) as u16
}
struct Hex4(u16);
impl std::fmt::Display for Hex4 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:04x}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::TranscriptSummary;
use crate::{Score, Usage};
#[test]
fn compact_utc_known_epochs() {
assert_eq!(format_compact_utc(0), "19700101T000000Z");
assert_eq!(format_compact_utc(1_609_459_200), "20210101T000000Z");
assert_eq!(format_compact_utc(1_700_000_000), "20231114T221320Z");
}
#[test]
fn run_id_is_sortable_and_shaped() {
let id = new_run_id();
assert_eq!(id.len(), 21, "{id}");
assert_eq!(&id[8..9], "T");
assert_eq!(&id[15..17], "Z-");
assert!(id[17..].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn run_id_uses_given_start_time() {
assert!(new_run_id_at(1_609_459_200).starts_with("20210101T000000Z-"));
}
fn run_result(passed: bool, skipped: bool, tokens: u64) -> RunResult {
let t = TranscriptSummary {
usage: Usage {
output_tokens: tokens,
..Default::default()
},
tool_calls_count: 1,
timing: crate::Timing {
duration_ms: 10,
..Default::default()
},
..Default::default()
};
RunResult {
eval: "e".into(),
sample: "s".into(),
target: "m".into(),
params: Default::default(),
trial: 0,
trials: 0,
seed: None,
input: Vec::new(),
expected: None,
passed,
aggregate: if passed { 1.0 } else { 0.0 },
scores: vec![Score::pass("x", "ok")],
transcript: t,
skipped,
}
}
#[test]
fn summary_counts_and_totals() {
let na = {
let mut r = run_result(false, false, 7);
r.scores = vec![Score::na("judge", "unreachable")];
r
};
let results = vec![
run_result(true, false, 5),
run_result(false, false, 3),
na,
run_result(false, true, 99), ];
let s = RunSummary::of(&results);
assert_eq!(s.scored, 2);
assert_eq!(s.passed, 1);
assert_eq!(s.failed, 1);
assert_eq!(s.na, 1, "all-N/A case is N/A, not failed");
assert_eq!(s.skipped, 1);
assert_eq!(s.total_tokens, 15);
assert_eq!(s.total_tool_calls, 3);
assert_eq!(s.total_duration_ms, 30);
}
}