use crate::error::Result;
use crate::event::Event;
use crate::study::Study;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
pub const RUN_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RunKind {
Fit,
Train,
Study,
Trial,
#[serde(other)]
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RunState {
Running,
Completed,
Failed,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GitInfo {
#[serde(default)]
pub sha: Option<String>,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub dirty: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GraphSummaryInfo {
pub n_nodes: usize,
pub node_ids: Vec<String>,
#[serde(default)]
pub graph_path: Option<String>,
#[serde(default)]
pub mermaid_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunManifest {
pub schema_version: u32,
pub run_id: String,
pub kind: RunKind,
pub name: String,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub soma_version: Option<String>,
#[serde(default)]
pub python_version: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub git: GitInfo,
#[serde(default)]
pub entrypoint: Option<String>,
#[serde(default)]
pub argv: Vec<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub seeds: HashMap<String, i64>,
#[serde(default)]
pub params: HashMap<String, serde_json::Value>,
#[serde(default)]
pub hypothesis: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub parent_run_id: Option<String>,
#[serde(default)]
pub graph: Option<GraphSummaryInfo>,
#[serde(default)]
pub study_path: Option<String>,
}
impl RunManifest {
pub fn new(run_id: impl Into<String>, kind: RunKind, name: impl Into<String>) -> Self {
Self {
schema_version: RUN_SCHEMA_VERSION,
run_id: run_id.into(),
kind,
name: name.into(),
created_at: Utc::now(),
soma_version: None,
python_version: None,
hostname: None,
git: GitInfo::default(),
entrypoint: None,
argv: Vec::new(),
cwd: None,
seeds: HashMap::new(),
params: HashMap::new(),
hypothesis: None,
tags: Vec::new(),
notes: None,
parent_run_id: None,
graph: None,
study_path: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStatus {
pub state: RunState,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub heartbeat_at: Option<DateTime<Utc>>,
#[serde(default)]
pub finished_at: Option<DateTime<Utc>>,
}
impl RunStatus {
pub fn running() -> Self {
let now = Utc::now();
Self {
state: RunState::Running,
updated_at: now,
heartbeat_at: Some(now),
finished_at: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventEnvelope {
pub seq: u64,
pub ts: DateTime<Utc>,
#[serde(flatten)]
pub event: Event,
}
pub trait EventSink: Send + Sync {
fn record(&self, event: &Event);
fn flush(&self) {}
}
pub trait Tracker: Send + Sync {
fn run_id(&self) -> &str;
fn run_dir(&self) -> &Path;
fn sink(&self) -> Arc<dyn EventSink>;
fn save_manifest(&self, manifest: &RunManifest) -> Result<()>;
fn save_artifact(&self, rel_path: &str, bytes: &[u8]) -> Result<()>;
fn save_study(&self, study: &Study) -> Result<()>;
fn heartbeat(&self) -> Result<()>;
fn finalize(&self, state: RunState) -> Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::MetricRecord;
#[test]
fn manifest_roundtrip_and_defaults() {
let mut m = RunManifest::new("run_x", RunKind::Train, "baseline");
m.tags = vec!["mos".into()];
m.seeds.insert("torch".into(), 42);
let json = serde_json::to_string(&m).unwrap();
let back: RunManifest = serde_json::from_str(&json).unwrap();
assert_eq!(back.run_id, "run_x");
assert_eq!(back.schema_version, RUN_SCHEMA_VERSION);
assert_eq!(back.seeds["torch"], 42);
let minimal = serde_json::json!({
"schema_version": 1,
"run_id": "r",
"kind": "fit",
"name": "n",
"created_at": "2026-07-26T10:00:00Z",
});
let back: RunManifest = serde_json::from_value(minimal).unwrap();
assert!(back.git.sha.is_none());
assert!(back.argv.is_empty());
}
#[test]
fn envelope_flattens_event_type() {
let env = EventEnvelope {
seq: 7,
ts: Utc::now(),
event: Event::MetricReported {
run_id: "r1".into(),
metric: MetricRecord {
name: "val_f1".into(),
value: 0.9,
step: 3,
timestamp: Utc::now(),
},
node_id: None,
trial_id: None,
},
};
let json = serde_json::to_value(&env).unwrap();
assert_eq!(json["seq"], 7);
assert_eq!(json["event_type"], "MetricReported");
assert_eq!(json["metric"]["name"], "val_f1");
let back: EventEnvelope = serde_json::from_value(json).unwrap();
assert_eq!(back.seq, 7);
assert!(matches!(back.event, Event::MetricReported { .. }));
}
#[test]
fn run_status_serde() {
let s = RunStatus::running();
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"running\""));
let back: RunStatus = serde_json::from_str(&json).unwrap();
assert_eq!(back.state, RunState::Running);
assert!(back.finished_at.is_none());
}
#[test]
fn run_status_terminal_states_roundtrip() {
for state in [RunState::Completed, RunState::Failed] {
let now = Utc::now();
let s = RunStatus {
state,
updated_at: now,
heartbeat_at: Some(now),
finished_at: Some(now),
};
let back: RunStatus =
serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back.state, state);
assert_eq!(back.finished_at, Some(now));
}
let minimal = serde_json::json!({
"state": "completed",
"updated_at": "2026-07-26T10:00:00Z",
});
let back: RunStatus = serde_json::from_value(minimal).unwrap();
assert_eq!(back.state, RunState::Completed);
assert!(back.heartbeat_at.is_none());
assert!(back.finished_at.is_none());
}
#[test]
fn unknown_run_kind_falls_back_to_other() {
let manifest = serde_json::json!({
"schema_version": 2,
"run_id": "r",
"kind": "evaluation",
"name": "n",
"created_at": "2026-07-26T10:00:00Z",
"some_future_field": {"nested": true},
});
let back: RunManifest = serde_json::from_value(manifest).unwrap();
assert_eq!(back.kind, RunKind::Other);
assert!(back.schema_version > RUN_SCHEMA_VERSION);
}
#[test]
fn envelope_roundtrips_one_event_per_level() {
let now = Utc::now();
let metric = MetricRecord {
name: "f1".into(),
value: 0.5,
step: 1,
timestamp: now,
};
let events = vec![
Event::RunFailed {
run_id: "r".into(),
error: "boom".into(),
},
Event::TrialMetric {
study_id: "s".into(),
trial_id: "t".into(),
metric: metric.clone(),
},
Event::StudyProgress {
study_id: "s".into(),
completed: 1,
total: 4,
best_value: 0.5,
},
Event::MemberExploited {
study_id: "s".into(),
generation: 1,
replaced_id: "a".into(),
donor_id: "b".into(),
},
Event::HealthFlag {
run_id: "r".into(),
node_id: "n".into(),
step: 3,
flag: "LEAKAGE".into(),
detail: "cka=0.99".into(),
},
];
for (i, event) in events.into_iter().enumerate() {
let env = EventEnvelope {
seq: i as u64,
ts: now,
event,
};
let json = serde_json::to_value(&env).unwrap();
assert_eq!(json["seq"], i as u64);
assert!(json["event_type"].is_string());
let back: EventEnvelope = serde_json::from_value(json).unwrap();
assert_eq!(back.seq, i as u64);
assert_eq!(back.ts, now);
}
}
#[test]
fn git_info_and_graph_summary_serde() {
let git = GitInfo {
sha: Some("abc123".into()),
branch: Some("main".into()),
dirty: Some(true),
};
let back: GitInfo = serde_json::from_str(&serde_json::to_string(&git).unwrap()).unwrap();
assert_eq!(back, git);
assert_eq!(GitInfo::default(), GitInfo::default());
assert!(GitInfo::default().sha.is_none());
let summary = GraphSummaryInfo {
n_nodes: 2,
node_ids: vec!["a".into(), "b".into()],
graph_path: Some("graph.json".into()),
mermaid_path: None,
};
let back: GraphSummaryInfo =
serde_json::from_str(&serde_json::to_string(&summary).unwrap()).unwrap();
assert_eq!(back, summary);
let minimal: GraphSummaryInfo =
serde_json::from_value(serde_json::json!({"n_nodes": 1, "node_ids": ["x"]})).unwrap();
assert_eq!(minimal.n_nodes, 1);
assert!(minimal.graph_path.is_none());
assert_eq!(GraphSummaryInfo::default().n_nodes, 0);
}
}