1use crate::error::Result;
12use crate::event::Event;
13use crate::study::Study;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::path::Path;
18use std::sync::Arc;
19
20pub const RUN_SCHEMA_VERSION: u32 = 1;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum RunKind {
28 Fit,
30 Train,
32 Study,
34 Trial,
36 #[serde(other)]
39 Other,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45#[non_exhaustive]
46pub enum RunState {
47 Running,
50 Completed,
52 Failed,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
58pub struct GitInfo {
59 #[serde(default)]
61 pub sha: Option<String>,
62 #[serde(default)]
64 pub branch: Option<String>,
65 #[serde(default)]
68 pub dirty: Option<bool>,
69}
70
71#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
74pub struct GraphSummaryInfo {
75 pub n_nodes: usize,
77 pub node_ids: Vec<String>,
79 #[serde(default)]
81 pub graph_path: Option<String>,
82 #[serde(default)]
84 pub mermaid_path: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct RunManifest {
94 pub schema_version: u32,
97 pub run_id: String,
99 pub kind: RunKind,
101 pub name: String,
103 pub created_at: DateTime<Utc>,
105 #[serde(default)]
107 pub soma_version: Option<String>,
108 #[serde(default)]
110 pub python_version: Option<String>,
111 #[serde(default)]
113 pub hostname: Option<String>,
114 #[serde(default)]
116 pub git: GitInfo,
117 #[serde(default)]
119 pub entrypoint: Option<String>,
120 #[serde(default)]
122 pub argv: Vec<String>,
123 #[serde(default)]
125 pub cwd: Option<String>,
126 #[serde(default)]
128 pub seeds: HashMap<String, i64>,
129 #[serde(default)]
134 pub params: HashMap<String, serde_json::Value>,
135 #[serde(default)]
139 pub hypothesis: Option<String>,
140 #[serde(default)]
142 pub tags: Vec<String>,
143 #[serde(default)]
145 pub notes: Option<String>,
146 #[serde(default)]
149 pub parent_run_id: Option<String>,
150 #[serde(default)]
153 pub graph: Option<GraphSummaryInfo>,
154 #[serde(default)]
156 pub study_path: Option<String>,
157}
158
159impl RunManifest {
160 pub fn new(run_id: impl Into<String>, kind: RunKind, name: impl Into<String>) -> Self {
162 Self {
163 schema_version: RUN_SCHEMA_VERSION,
164 run_id: run_id.into(),
165 kind,
166 name: name.into(),
167 created_at: Utc::now(),
168 soma_version: None,
169 python_version: None,
170 hostname: None,
171 git: GitInfo::default(),
172 entrypoint: None,
173 argv: Vec::new(),
174 cwd: None,
175 seeds: HashMap::new(),
176 params: HashMap::new(),
177 hypothesis: None,
178 tags: Vec::new(),
179 notes: None,
180 parent_run_id: None,
181 graph: None,
182 study_path: None,
183 }
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct RunStatus {
194 pub state: RunState,
196 pub updated_at: DateTime<Utc>,
198 #[serde(default)]
201 pub heartbeat_at: Option<DateTime<Utc>>,
202 #[serde(default)]
204 pub finished_at: Option<DateTime<Utc>>,
205}
206
207impl RunStatus {
208 pub fn running() -> Self {
211 let now = Utc::now();
212 Self {
213 state: RunState::Running,
214 updated_at: now,
215 heartbeat_at: Some(now),
216 finished_at: None,
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct EventEnvelope {
226 pub seq: u64,
229 pub ts: DateTime<Utc>,
231 #[serde(flatten)]
233 pub event: Event,
234}
235
236pub trait EventSink: Send + Sync {
244 fn record(&self, event: &Event);
246
247 fn flush(&self) {}
249}
250
251pub trait Tracker: Send + Sync {
256 fn run_id(&self) -> &str;
258
259 fn run_dir(&self) -> &Path;
261
262 fn sink(&self) -> Arc<dyn EventSink>;
264
265 fn save_manifest(&self, manifest: &RunManifest) -> Result<()>;
267
268 fn save_artifact(&self, rel_path: &str, bytes: &[u8]) -> Result<()>;
271
272 fn save_study(&self, study: &Study) -> Result<()>;
276
277 fn heartbeat(&self) -> Result<()>;
279
280 fn finalize(&self, state: RunState) -> Result<()>;
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::event::MetricRecord;
288
289 #[test]
290 fn manifest_roundtrip_and_defaults() {
291 let mut m = RunManifest::new("run_x", RunKind::Train, "baseline");
292 m.tags = vec!["mos".into()];
293 m.seeds.insert("torch".into(), 42);
294 let json = serde_json::to_string(&m).unwrap();
295 let back: RunManifest = serde_json::from_str(&json).unwrap();
296 assert_eq!(back.run_id, "run_x");
297 assert_eq!(back.schema_version, RUN_SCHEMA_VERSION);
298 assert_eq!(back.seeds["torch"], 42);
299
300 let minimal = serde_json::json!({
302 "schema_version": 1,
303 "run_id": "r",
304 "kind": "fit",
305 "name": "n",
306 "created_at": "2026-07-26T10:00:00Z",
307 });
308 let back: RunManifest = serde_json::from_value(minimal).unwrap();
309 assert!(back.git.sha.is_none());
310 assert!(back.argv.is_empty());
311 }
312
313 #[test]
314 fn envelope_flattens_event_type() {
315 let env = EventEnvelope {
316 seq: 7,
317 ts: Utc::now(),
318 event: Event::MetricReported {
319 run_id: "r1".into(),
320 metric: MetricRecord {
321 name: "val_f1".into(),
322 value: 0.9,
323 step: 3,
324 timestamp: Utc::now(),
325 },
326 node_id: None,
327 trial_id: None,
328 },
329 };
330 let json = serde_json::to_value(&env).unwrap();
331 assert_eq!(json["seq"], 7);
332 assert_eq!(json["event_type"], "MetricReported");
333 assert_eq!(json["metric"]["name"], "val_f1");
334 let back: EventEnvelope = serde_json::from_value(json).unwrap();
335 assert_eq!(back.seq, 7);
336 assert!(matches!(back.event, Event::MetricReported { .. }));
337 }
338
339 #[test]
340 fn run_status_serde() {
341 let s = RunStatus::running();
342 let json = serde_json::to_string(&s).unwrap();
343 assert!(json.contains("\"running\""));
344 let back: RunStatus = serde_json::from_str(&json).unwrap();
345 assert_eq!(back.state, RunState::Running);
346 assert!(back.finished_at.is_none());
347 }
348
349 #[test]
350 fn run_status_terminal_states_roundtrip() {
351 for state in [RunState::Completed, RunState::Failed] {
352 let now = Utc::now();
353 let s = RunStatus {
354 state,
355 updated_at: now,
356 heartbeat_at: Some(now),
357 finished_at: Some(now),
358 };
359 let back: RunStatus =
360 serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
361 assert_eq!(back.state, state);
362 assert_eq!(back.finished_at, Some(now));
363 }
364 let minimal = serde_json::json!({
366 "state": "completed",
367 "updated_at": "2026-07-26T10:00:00Z",
368 });
369 let back: RunStatus = serde_json::from_value(minimal).unwrap();
370 assert_eq!(back.state, RunState::Completed);
371 assert!(back.heartbeat_at.is_none());
372 assert!(back.finished_at.is_none());
373 }
374
375 #[test]
376 fn unknown_run_kind_falls_back_to_other() {
377 let manifest = serde_json::json!({
380 "schema_version": 2,
381 "run_id": "r",
382 "kind": "evaluation",
383 "name": "n",
384 "created_at": "2026-07-26T10:00:00Z",
385 "some_future_field": {"nested": true},
386 });
387 let back: RunManifest = serde_json::from_value(manifest).unwrap();
388 assert_eq!(back.kind, RunKind::Other);
389 assert!(back.schema_version > RUN_SCHEMA_VERSION);
391 }
392
393 #[test]
394 fn envelope_roundtrips_one_event_per_level() {
395 let now = Utc::now();
396 let metric = MetricRecord {
397 name: "f1".into(),
398 value: 0.5,
399 step: 1,
400 timestamp: now,
401 };
402 let events = vec![
403 Event::RunFailed {
404 run_id: "r".into(),
405 error: "boom".into(),
406 },
407 Event::TrialMetric {
408 study_id: "s".into(),
409 trial_id: "t".into(),
410 metric: metric.clone(),
411 },
412 Event::StudyProgress {
413 study_id: "s".into(),
414 completed: 1,
415 total: 4,
416 best_value: 0.5,
417 },
418 Event::MemberExploited {
419 study_id: "s".into(),
420 generation: 1,
421 replaced_id: "a".into(),
422 donor_id: "b".into(),
423 },
424 Event::HealthFlag {
425 run_id: "r".into(),
426 node_id: "n".into(),
427 step: 3,
428 flag: "LEAKAGE".into(),
429 detail: "cka=0.99".into(),
430 },
431 ];
432 for (i, event) in events.into_iter().enumerate() {
433 let env = EventEnvelope {
434 seq: i as u64,
435 ts: now,
436 event,
437 };
438 let json = serde_json::to_value(&env).unwrap();
439 assert_eq!(json["seq"], i as u64);
441 assert!(json["event_type"].is_string());
442 let back: EventEnvelope = serde_json::from_value(json).unwrap();
443 assert_eq!(back.seq, i as u64);
444 assert_eq!(back.ts, now);
445 }
446 }
447
448 #[test]
449 fn git_info_and_graph_summary_serde() {
450 let git = GitInfo {
451 sha: Some("abc123".into()),
452 branch: Some("main".into()),
453 dirty: Some(true),
454 };
455 let back: GitInfo = serde_json::from_str(&serde_json::to_string(&git).unwrap()).unwrap();
456 assert_eq!(back, git);
457 assert_eq!(GitInfo::default(), GitInfo::default());
458 assert!(GitInfo::default().sha.is_none());
459
460 let summary = GraphSummaryInfo {
461 n_nodes: 2,
462 node_ids: vec!["a".into(), "b".into()],
463 graph_path: Some("graph.json".into()),
464 mermaid_path: None,
465 };
466 let back: GraphSummaryInfo =
467 serde_json::from_str(&serde_json::to_string(&summary).unwrap()).unwrap();
468 assert_eq!(back, summary);
469 let minimal: GraphSummaryInfo =
471 serde_json::from_value(serde_json::json!({"n_nodes": 1, "node_ids": ["x"]})).unwrap();
472 assert_eq!(minimal.n_nodes, 1);
473 assert!(minimal.graph_path.is_none());
474 assert_eq!(GraphSummaryInfo::default().n_nodes, 0);
475 }
476}