somatize-core 0.5.1

Core types and traits for the Soma computational graph runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//! Experiment tracking types: run manifests, status, event envelopes,
//! and the [`EventSink`]/[`Tracker`] traits.
//!
//! A *run* is the unit of tracking — one training session, study, or
//! fit — materialized as a directory of append-only logs plus small
//! atomic JSON files. This module holds only the schema and trait
//! contracts; the file-writing implementation lives in `soma-runtime`
//! (`LocalTracker`), and a future remote backend implements the same
//! [`Tracker`] trait.

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;

/// Version of the on-disk run schema (manifest + logs layout).
pub const RUN_SCHEMA_VERSION: u32 = 1;

/// What kind of work a run tracks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RunKind {
    /// A `GraphSession::fit` over a static pipeline.
    Fit,
    /// A native training loop (materialize/forward/backward/step).
    Train,
    /// A hyperparameter study.
    Study,
    /// A single trial within a study.
    Trial,
    /// Anything else — also the fallback when deserializing a kind
    /// written by a newer soma, so old readers never fail on new kinds.
    #[serde(other)]
    Other,
}

/// Lifecycle state of a run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RunState {
    /// The run's process claims to be alive; trust it only while
    /// [`RunStatus::heartbeat_at`] is fresh.
    Running,
    /// Finished successfully.
    Completed,
    /// Finished with an error.
    Failed,
}

/// Best-effort git context captured at run start.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GitInfo {
    /// Commit hash of `HEAD`.
    #[serde(default)]
    pub sha: Option<String>,
    /// Checked-out branch name, `None` on a detached head.
    #[serde(default)]
    pub branch: Option<String>,
    /// Whether the working tree had uncommitted changes — a dirty run
    /// is one the recorded `sha` cannot fully reproduce.
    #[serde(default)]
    pub dirty: Option<bool>,
}

/// Compact description of the graph a run executed, with pointers to
/// the full topology files inside the run directory.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GraphSummaryInfo {
    /// Number of nodes in the executed graph.
    pub n_nodes: usize,
    /// Node ids, in the graph's insertion order.
    pub node_ids: Vec<String>,
    /// Relative path to the serialized graph (e.g. `graph.json`).
    #[serde(default)]
    pub graph_path: Option<String>,
    /// Relative path to the mermaid rendering (e.g. `graph.mmd`).
    #[serde(default)]
    pub mermaid_path: Option<String>,
}

/// Run manifest — written once, atomically, at run start.
///
/// Mutable lifecycle state (running/completed/failed, heartbeat) lives
/// in the separate [`RunStatus`] file so the manifest never needs
/// rewriting after creation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunManifest {
    /// On-disk layout version this run was written with
    /// (see [`RUN_SCHEMA_VERSION`]).
    pub schema_version: u32,
    /// Unique run identifier — also the run directory's name.
    pub run_id: String,
    /// What kind of work this run tracks.
    pub kind: RunKind,
    /// Human-readable run name (not required to be unique).
    pub name: String,
    /// When the run started.
    pub created_at: DateTime<Utc>,
    /// Version of soma that wrote this run.
    #[serde(default)]
    pub soma_version: Option<String>,
    /// Python interpreter version, for runs started from the bindings.
    #[serde(default)]
    pub python_version: Option<String>,
    /// Host the run executed on.
    #[serde(default)]
    pub hostname: Option<String>,
    /// Best-effort git context captured at run start.
    #[serde(default)]
    pub git: GitInfo,
    /// Script or module that started the run.
    #[serde(default)]
    pub entrypoint: Option<String>,
    /// Command-line arguments of the launching process.
    #[serde(default)]
    pub argv: Vec<String>,
    /// Working directory the run was started from.
    #[serde(default)]
    pub cwd: Option<String>,
    /// Named seeds, e.g. `{"torch": 42}`.
    #[serde(default)]
    pub seeds: HashMap<String, i64>,
    /// Hyperparameters the caller declared for this run — the knobs
    /// that live outside the graph (learning rate, batch size, …) and
    /// so cannot be recovered from a filter's config hash. What makes
    /// a `ParamChanged` derivation possible at all.
    #[serde(default)]
    pub params: HashMap<String, serde_json::Value>,
    /// What the person starting this run expected, and why. Recorded at
    /// the start rather than the end on purpose: a hypothesis written
    /// after seeing the result is a conclusion.
    #[serde(default)]
    pub hypothesis: Option<String>,
    /// Free-form labels for filtering run listings.
    #[serde(default)]
    pub tags: Vec<String>,
    /// Free-form notes attached at run start.
    #[serde(default)]
    pub notes: Option<String>,
    /// Run this one derives from — the edge the experiment pool's
    /// lineage is built on. Set explicitly, never inferred.
    #[serde(default)]
    pub parent_run_id: Option<String>,
    /// Compact description of the executed graph, absent for
    /// graph-less runs (e.g. a study run).
    #[serde(default)]
    pub graph: Option<GraphSummaryInfo>,
    /// Relative path to `study.json` for study runs.
    #[serde(default)]
    pub study_path: Option<String>,
}

impl RunManifest {
    /// Minimal manifest; callers fill in environment fields.
    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,
        }
    }
}

/// Mutable run status — small, atomically rewritten (`status.json`).
///
/// The heartbeat lets a reader distinguish a live run from a crashed
/// one without any protocol: `state == Running` with a stale
/// `heartbeat_at` means the process died.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStatus {
    /// Current lifecycle state.
    pub state: RunState,
    /// When this status file was last rewritten, for any reason.
    pub updated_at: DateTime<Utc>,
    /// Last liveness ping. Stale while `state` is
    /// [`RunState::Running`] means the process died.
    #[serde(default)]
    pub heartbeat_at: Option<DateTime<Utc>>,
    /// When the run reached a terminal state, `None` while running.
    #[serde(default)]
    pub finished_at: Option<DateTime<Utc>>,
}

impl RunStatus {
    /// Fresh status for a run that just started: state
    /// [`RunState::Running`] with the heartbeat stamped now.
    pub fn running() -> Self {
        let now = Utc::now();
        Self {
            state: RunState::Running,
            updated_at: now,
            heartbeat_at: Some(now),
            finished_at: None,
        }
    }
}

/// One line of `events.jsonl`: a monotonic sequence number and wall
/// timestamp wrapped around the event, with the event's own
/// `event_type` tag flattened into the same object.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventEnvelope {
    /// Monotonic sequence number within the run — the order events were
    /// recorded in, which timestamps alone cannot guarantee.
    pub seq: u64,
    /// Wall-clock time the event was recorded.
    pub ts: DateTime<Utc>,
    /// The event itself, flattened into the envelope's JSON object.
    #[serde(flatten)]
    pub event: Event,
}

/// A lossless, ordered consumer of events.
///
/// Unlike broadcast subscribers (which may lag and drop), sinks are
/// invoked synchronously from `EventBus::emit` and must never lose an
/// event. Implementations should buffer writes and must not panic;
/// I/O errors are to be swallowed (optionally logged), never surfaced
/// into the training loop.
pub trait EventSink: Send + Sync {
    /// Record one event. Called synchronously on the emitting thread.
    fn record(&self, event: &Event);

    /// Flush any buffered state to durable storage.
    fn flush(&self) {}
}

/// A tracking backend bound to one run.
///
/// The local implementation writes a run directory; a remote backend
/// can implement the same contract over HTTP.
pub trait Tracker: Send + Sync {
    /// Identifier of the run this tracker is bound to.
    fn run_id(&self) -> &str;

    /// Root directory of the run (for file-based backends).
    fn run_dir(&self) -> &Path;

    /// The sink that persists events for this run.
    fn sink(&self) -> Arc<dyn EventSink>;

    /// Atomically write the manifest.
    fn save_manifest(&self, manifest: &RunManifest) -> Result<()>;

    /// Write an artifact at a path relative to the run directory,
    /// creating parent directories as needed.
    fn save_artifact(&self, rel_path: &str, bytes: &[u8]) -> Result<()>;

    /// Atomically write `study.json` (tmp + rename — readers never see
    /// a partial study, and a crash mid-write preserves the previous
    /// complete version).
    fn save_study(&self, study: &Study) -> Result<()>;

    /// Refresh `heartbeat_at` in the status file.
    fn heartbeat(&self) -> Result<()>;

    /// Set the terminal state, stamp `finished_at`, and flush the sink.
    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);

        // Old manifests without the optional fields still load.
        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));
        }
        // Back-compat: a status without the optional timestamps loads.
        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() {
        // A manifest written by a future soma with a new kind must not
        // break `LocalTracker::open` on this version.
        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);
        // A reader can detect the newer schema explicitly.
        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();
            // The envelope's own fields never collide with payloads.
            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);
        // Back-compat: path fields are optional.
        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);
    }
}