Skip to main content

somatize_memory/
record.rs

1//! Data types for experiment tracking: records, research lines, trends.
2//!
3//! [`ExperimentRecord`] is the line format of `experiments.jsonl` — the
4//! contract between whatever produces runs and whatever reasons about
5//! them. Every field added after the first release is `#[serde(default)]`
6//! and every unknown field is ignored, so a journal written by a newer
7//! soma still loads on an older one and vice versa. The back-compat
8//! tests at the bottom of this file are that promise, in code.
9
10use crate::derivation::DerivationMove;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use somatize_core::fingerprint::ArchitectureFingerprint;
14use somatize_core::summary::{RunConclusion, RunSummary};
15use somatize_core::tracking::GitInfo;
16use std::collections::BTreeMap;
17use std::time::Duration;
18
19/// Current `ExperimentRecord` schema version.
20pub const RECORD_SCHEMA_VERSION: u32 = 2;
21
22/// What a journal line is.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25#[non_exhaustive]
26pub enum RecordKind {
27    /// A run that happened.
28    #[default]
29    Experiment,
30    /// A later correction or addition to an earlier record, named by
31    /// `amends`. The journal stays strictly append-only: nothing is
32    /// ever rewritten, conclusions are layered on.
33    Amendment,
34    /// Written by a newer soma with a kind this version does not know.
35    #[serde(other)]
36    Other,
37}
38
39/// A dense vector attached to a record, with the identity of whatever
40/// produced it — a vector from a different model is not comparable, and
41/// silently mixing the two is worse than having none.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct Embedding {
44    /// Identity of the model that produced the vector — see
45    /// [`Embedder::id`](crate::retrieval::Embedder::id).
46    pub embedder_id: String,
47    /// The dense vector itself.
48    pub vector: Vec<f32>,
49}
50
51/// A recorded experiment in the knowledge base.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ExperimentRecord {
54    /// Unique identifier — the run id, for records built from a run.
55    pub id: String,
56    /// Human-readable name; for a rootless record it also seeds the
57    /// research-line slug (see [`slugify`]).
58    pub name: String,
59    /// What the user set out to test, when declared at run start.
60    pub hypothesis: Option<String>,
61    /// One-line rendering of the pipeline that ran.
62    pub pipeline_summary: String,
63    /// Parameters the run captured, plus `seed.*` entries for its seeds.
64    pub params: BTreeMap<String, serde_json::Value>,
65    /// Final metric values, by name.
66    pub metrics: BTreeMap<String, f64>,
67    /// When the run started.
68    pub timestamp: DateTime<Utc>,
69    /// Wall time of the run.
70    pub duration: Duration,
71    /// Id of the record this one descends from, when known — the other
72    /// end of [`derivation`](Self::derivation).
73    pub parent: Option<String>,
74    /// Slug of the research line this record belongs to: inherited from
75    /// the parent, or derived from the name for a root.
76    pub research_line: Option<String>,
77    /// Free-form tags; [`from_run`](Self::from_run) always includes
78    /// `run:<id>`.
79    pub tags: Vec<String>,
80    /// Free-form human notes, set at recording time or layered on later
81    /// via an [`amendment`](Self::amendment).
82    pub notes: Option<String>,
83
84    // ── Added in schema version 2. All optional, all defaulted. ──
85    /// [`RECORD_SCHEMA_VERSION`] at write time; 1 for lines written
86    /// before versioning existed.
87    #[serde(default = "legacy_schema_version")]
88    pub schema_version: u32,
89    /// What this journal line is — see [`RecordKind`].
90    #[serde(default)]
91    pub kind: RecordKind,
92    /// The run this record came from, and where its raw artifacts are —
93    /// so a reader can go look at the events, diagnostics and figures
94    /// this summary was distilled from.
95    #[serde(default)]
96    pub run_id: Option<String>,
97    /// Absolute path of that run directory, as recorded at write time.
98    #[serde(default)]
99    pub run_dir: Option<String>,
100    /// Structural fingerprint of the graph that ran — what
101    /// [`derive`](crate::derivation::derive) diffs to build the move.
102    #[serde(default)]
103    pub architecture: Option<ArchitectureFingerprint>,
104    /// Metric this experiment was optimizing, when it declared one.
105    #[serde(default)]
106    pub objective: Option<String>,
107    /// Deterministic, templated summary of how the run ended — the text
108    /// retrieval leans on hardest after the name.
109    #[serde(default)]
110    pub conclusion: Option<RunConclusion>,
111    /// The move that produced this experiment from its parent.
112    #[serde(default)]
113    pub derivation: Option<DerivationMove>,
114    /// Repository state (sha, branch, dirty) when the run happened.
115    #[serde(default)]
116    pub git: Option<GitInfo>,
117    /// For `kind = Amendment`: the id of the record being amended.
118    #[serde(default)]
119    pub amends: Option<String>,
120    /// Dense vector for semantic retrieval, tagged with the model that
121    /// produced it (see [`Embedding`]).
122    #[serde(default)]
123    pub embedding: Option<Embedding>,
124}
125
126/// Records written before `schema_version` existed are version 1.
127fn legacy_schema_version() -> u32 {
128    1
129}
130
131impl ExperimentRecord {
132    /// A record with only its identity set: timestamped now, current
133    /// schema version, every optional field absent.
134    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
135        Self {
136            id: id.into(),
137            name: name.into(),
138            hypothesis: None,
139            pipeline_summary: String::new(),
140            params: BTreeMap::new(),
141            metrics: BTreeMap::new(),
142            timestamp: Utc::now(),
143            duration: Duration::ZERO,
144            parent: None,
145            research_line: None,
146            tags: Vec::new(),
147            notes: None,
148            schema_version: RECORD_SCHEMA_VERSION,
149            kind: RecordKind::Experiment,
150            run_id: None,
151            run_dir: None,
152            architecture: None,
153            objective: None,
154            conclusion: None,
155            derivation: None,
156            git: None,
157            amends: None,
158            embedding: None,
159        }
160    }
161
162    /// Build a record from a finished run's summary.
163    ///
164    /// This is where `pipeline_summary` stops being the constant
165    /// `"tracked run"`: everything here is read off the run directory.
166    /// A record with no parent starts its own research line, named
167    /// after the run — children inherit that name, which is what makes
168    /// the line analytics work on real data.
169    pub fn from_run(summary: &RunSummary) -> Self {
170        let mut tags = summary.tags.clone();
171        let run_tag = format!("run:{}", summary.run_id);
172        if !tags.contains(&run_tag) {
173            tags.push(run_tag);
174        }
175        Self {
176            id: summary.run_id.clone(),
177            name: summary.name.clone(),
178            hypothesis: summary.hypothesis.clone(),
179            pipeline_summary: summary.pipeline_summary.clone(),
180            params: summary
181                .seeds
182                .iter()
183                .map(|(k, v)| (format!("seed.{k}"), serde_json::json!(v)))
184                .chain(summary.params.iter().map(|(k, v)| (k.clone(), v.clone())))
185                .collect(),
186            metrics: summary.metrics.clone().into_iter().collect(),
187            timestamp: summary.created_at,
188            duration: Duration::from_millis(summary.duration_ms.unwrap_or(0)),
189            parent: summary.parent_run_id.clone(),
190            research_line: Some(slugify(&summary.name)),
191            tags,
192            notes: None,
193            schema_version: RECORD_SCHEMA_VERSION,
194            kind: RecordKind::Experiment,
195            run_id: Some(summary.run_id.clone()),
196            run_dir: Some(summary.run_dir.clone()),
197            architecture: summary.architecture.clone(),
198            objective: summary
199                .conclusion
200                .trials
201                .as_ref()
202                .and_then(|t| t.objective.clone()),
203            conclusion: Some(summary.conclusion.clone()),
204            derivation: None,
205            git: Some(summary.git.clone()),
206            amends: None,
207            embedding: None,
208        }
209    }
210
211    /// Attach this record to its parent: set `parent`, inherit the
212    /// research line, and compute the derivation move between them.
213    ///
214    /// The line is inherited rather than recomputed so that every
215    /// descendant of one root shares one name, however far it drifts.
216    pub fn descended_from(mut self, parent: &ExperimentRecord) -> Self {
217        self.parent = Some(parent.id.clone());
218        self.research_line = parent
219            .research_line
220            .clone()
221            .or_else(|| Some(slugify(&parent.name)));
222        self.derivation = Some(crate::derivation::derive(parent, &self));
223        self
224    }
225
226    /// Merge user-supplied parameters over whatever the run captured.
227    pub fn with_extra_params(
228        mut self,
229        params: impl IntoIterator<Item = (String, serde_json::Value)>,
230    ) -> Self {
231        self.params.extend(params);
232        self
233    }
234
235    /// Merge extra metrics over whatever the run directory recorded —
236    /// a study's best-trial values, a training loop's summary metrics.
237    pub fn with_extra_metrics(mut self, metrics: impl IntoIterator<Item = (String, f64)>) -> Self {
238        self.metrics.extend(metrics);
239        self
240    }
241
242    /// An amendment: a later note attached to an existing record,
243    /// appended as its own line so the journal is never rewritten.
244    pub fn amendment(
245        id: impl Into<String>,
246        amends: impl Into<String>,
247        notes: impl Into<String>,
248    ) -> Self {
249        let amends = amends.into();
250        Self {
251            kind: RecordKind::Amendment,
252            amends: Some(amends.clone()),
253            notes: Some(notes.into()),
254            ..Self::new(id, format!("amendment to {amends}"))
255        }
256    }
257
258    /// Set the hypothesis under test.
259    pub fn with_hypothesis(mut self, h: impl Into<String>) -> Self {
260        self.hypothesis = Some(h.into());
261        self
262    }
263
264    /// Set the one-line pipeline summary.
265    pub fn with_pipeline(mut self, summary: impl Into<String>) -> Self {
266        self.pipeline_summary = summary.into();
267        self
268    }
269
270    /// Replace the parameter map ([`with_extra_params`](Self::with_extra_params) merges).
271    pub fn with_params(mut self, params: BTreeMap<String, serde_json::Value>) -> Self {
272        self.params = params;
273        self
274    }
275
276    /// Replace the metric map ([`with_extra_metrics`](Self::with_extra_metrics) merges).
277    pub fn with_metrics(mut self, metrics: BTreeMap<String, f64>) -> Self {
278        self.metrics = metrics;
279        self
280    }
281
282    /// Set the run's wall time.
283    pub fn with_duration(mut self, d: Duration) -> Self {
284        self.duration = d;
285        self
286    }
287
288    /// Set the parent id only — no line inheritance, no derivation.
289    /// [`descended_from`](Self::descended_from) is the full attachment.
290    pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
291        self.parent = Some(parent.into());
292        self
293    }
294
295    /// Pin the research line explicitly, overriding inheritance.
296    pub fn with_research_line(mut self, line: impl Into<String>) -> Self {
297        self.research_line = Some(line.into());
298        self
299    }
300
301    /// Replace the tag list.
302    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
303        self.tags = tags;
304        self
305    }
306
307    /// Attach free-form notes.
308    pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
309        self.notes = Some(notes.into());
310        self
311    }
312
313    /// Whether this record carries a conclusion worth retrieving.
314    pub fn has_conclusion(&self) -> bool {
315        self.conclusion.as_ref().is_some_and(|c| !c.is_empty())
316            || self.notes.is_some()
317            || self.hypothesis.is_some()
318    }
319
320    /// The record's headline, or the best one-liner available.
321    pub fn headline(&self) -> &str {
322        self.conclusion
323            .as_ref()
324            .map(|c| c.headline.as_str())
325            .filter(|h| !h.is_empty())
326            .unwrap_or(&self.pipeline_summary)
327    }
328}
329
330/// Lowercase, hyphen-separated, alphanumerics only — a stable research
331/// line name derived from a run name.
332pub fn slugify(name: &str) -> String {
333    let mut slug = String::with_capacity(name.len());
334    let mut pending_dash = false;
335    for ch in name.chars() {
336        if ch.is_alphanumeric() {
337            if pending_dash && !slug.is_empty() {
338                slug.push('-');
339            }
340            pending_dash = false;
341            slug.extend(ch.to_lowercase());
342        } else {
343            pending_dash = true;
344        }
345    }
346    if slug.is_empty() {
347        "unnamed".into()
348    } else {
349        slug
350    }
351}
352
353/// A research line: a group of related experiments tracking evolution.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct ResearchLine {
356    /// Line slug, shared by every member (see [`slugify`]).
357    pub name: String,
358    /// Ids of the experiments in the line.
359    pub experiments: Vec<String>,
360    /// Direction the line's recent metric values are moving.
361    pub trend: Trend,
362    /// Highest raw value observed across all members and metrics.
363    pub best_metric_value: Option<f64>,
364    /// Name of the metric behind [`best_metric_value`](Self::best_metric_value).
365    pub best_metric_name: Option<String>,
366}
367
368/// Trend direction of a research line.
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370pub enum Trend {
371    /// Recent values consistently rising.
372    Improving,
373    /// Recent values neither consistently rising nor falling.
374    Plateaued,
375    /// Recent values consistently falling.
376    Declining,
377    /// Too few points, or no metrics, to say.
378    Unknown,
379}
380
381impl std::fmt::Display for Trend {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match self {
384            Self::Improving => write!(f, "improving"),
385            Self::Plateaued => write!(f, "plateaued"),
386            Self::Declining => write!(f, "declining"),
387            Self::Unknown => write!(f, "unknown"),
388        }
389    }
390}
391
392/// A point where experiment results changed significantly.
393#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct ChangePoint {
395    /// The experiment at which the shift shows (the "after" side).
396    pub experiment_id: String,
397    /// When that experiment ran.
398    pub timestamp: DateTime<Utc>,
399    /// The metric that shifted.
400    pub metric_name: String,
401    /// Its value in the preceding experiment.
402    pub value_before: f64,
403    /// Its value in this experiment.
404    pub value_after: f64,
405    /// Human-readable rendering of the shift.
406    pub description: String,
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use somatize_core::summary::{RunOutcome, TrialSummary};
413    use std::collections::BTreeMap;
414
415    /// A byte-exact line as soma 0.3.0 wrote it, before any of the
416    /// experiment-pool fields existed. This must load forever: a
417    /// journal is the one artifact a user cannot be asked to migrate.
418    const LEGACY_LINE: &str = r#"{"id":"study_001","name":"mos-sweep","hypothesis":null,"pipeline_summary":"study over 40 trials","params":{"lr":0.01},"metrics":{"val_f1":0.87},"timestamp":"2026-07-26T10:00:00Z","duration":{"secs":420,"nanos":0},"parent":null,"research_line":null,"tags":["mos","run:run_x"],"notes":null}"#;
419
420    fn summary() -> RunSummary {
421        RunSummary {
422            run_id: "run_42".into(),
423            run_dir: "/proj/.soma/runs/run_42".into(),
424            name: "MoS Baseline!".into(),
425            kind: "train".into(),
426            created_at: Utc::now(),
427            finished_at: None,
428            duration_ms: Some(2_000),
429            tags: vec!["mos".into()],
430            git: GitInfo::default(),
431            seeds: BTreeMap::from([("torch".to_string(), 42)]),
432            params: BTreeMap::from([("lr".to_string(), serde_json::json!(0.01))]),
433            hypothesis: Some("two branches beat one".into()),
434            parent_run_id: None,
435            architecture: None,
436            pipeline_summary: "a(Scaler) → b(SVM)".into(),
437            metrics: BTreeMap::from([("val_f1".to_string(), 0.9)]),
438            conclusion: RunConclusion {
439                headline: "completed in 2.0s".into(),
440                outcome: Some(RunOutcome::Completed),
441                ..RunConclusion::default()
442            },
443        }
444    }
445
446    #[test]
447    fn a_legacy_line_still_loads_and_defaults_the_new_fields() {
448        let record: ExperimentRecord = serde_json::from_str(LEGACY_LINE).unwrap();
449        assert_eq!(record.id, "study_001");
450        assert_eq!(record.pipeline_summary, "study over 40 trials");
451        assert_eq!(record.metrics["val_f1"], 0.87);
452        assert_eq!(record.duration, Duration::from_secs(420));
453
454        // Every field added since is absent, not wrong.
455        assert_eq!(record.schema_version, 1, "pre-versioning lines are v1");
456        assert_eq!(record.kind, RecordKind::Experiment);
457        assert!(record.run_id.is_none());
458        assert!(record.run_dir.is_none());
459        assert!(record.architecture.is_none());
460        assert!(record.conclusion.is_none());
461        assert!(record.derivation.is_none());
462        assert!(record.git.is_none());
463        assert!(record.embedding.is_none());
464    }
465
466    #[test]
467    fn a_line_from_a_newer_soma_loads_on_this_one() {
468        // Unknown fields are ignored, unknown kinds fall back to Other:
469        // an old reader never chokes on a journal a new writer appended.
470        let future = serde_json::json!({
471            "id": "x", "name": "n", "hypothesis": null, "pipeline_summary": "p",
472            "params": {}, "metrics": {}, "timestamp": "2026-07-30T10:00:00Z",
473            "duration": {"secs": 1, "nanos": 0}, "parent": null,
474            "research_line": null, "tags": [], "notes": null,
475            "schema_version": 99,
476            "kind": "retraction",
477            "causal_graph": {"nested": ["anything"]},
478        });
479        let record: ExperimentRecord = serde_json::from_value(future).unwrap();
480        assert_eq!(record.schema_version, 99);
481        assert_eq!(record.kind, RecordKind::Other);
482    }
483
484    #[test]
485    fn a_current_record_roundtrips_with_every_field_populated() {
486        let mut record = ExperimentRecord::new("r", "run")
487            .with_hypothesis("wider is better")
488            .with_notes("looked good until epoch 20");
489        record.conclusion = Some(RunConclusion {
490            headline: "completed in 2.0s".into(),
491            outcome: Some(RunOutcome::Completed),
492            trials: Some(TrialSummary {
493                total: 3,
494                ..TrialSummary::default()
495            }),
496            ..RunConclusion::default()
497        });
498        record.architecture = Some(ArchitectureFingerprint {
499            digest: "abc".into(),
500            n_nodes: 1,
501            ..ArchitectureFingerprint::default()
502        });
503        record.git = Some(GitInfo {
504            sha: Some("deadbeef".into()),
505            ..GitInfo::default()
506        });
507        record.embedding = Some(Embedding {
508            embedder_id: "minilm-v2".into(),
509            vector: vec![0.1, 0.2],
510        });
511        record.run_dir = Some("/tmp/r".into());
512
513        let json = serde_json::to_string(&record).unwrap();
514        let back: ExperimentRecord = serde_json::from_str(&json).unwrap();
515        assert_eq!(back.schema_version, RECORD_SCHEMA_VERSION);
516        assert_eq!(back.headline(), "completed in 2.0s");
517        assert!(back.has_conclusion());
518        assert_eq!(back.architecture.as_ref().unwrap().digest, "abc");
519        assert_eq!(back.embedding.unwrap().embedder_id, "minilm-v2");
520    }
521
522    #[test]
523    fn from_run_replaces_the_tracked_run_placeholder() {
524        let record = ExperimentRecord::from_run(&summary());
525        assert_eq!(record.id, "run_42");
526        assert_eq!(record.run_id.as_deref(), Some("run_42"));
527        assert_eq!(record.run_dir.as_deref(), Some("/proj/.soma/runs/run_42"));
528        assert_eq!(record.pipeline_summary, "a(Scaler) → b(SVM)");
529        assert_ne!(record.pipeline_summary, "tracked run");
530        assert_eq!(record.metrics["val_f1"], 0.9);
531        assert_eq!(record.params["seed.torch"], serde_json::json!(42));
532        assert_eq!(record.params["lr"], serde_json::json!(0.01));
533        assert_eq!(
534            record.hypothesis.as_deref(),
535            Some("two branches beat one"),
536            "a hypothesis declared at run start reaches the journal"
537        );
538        assert_eq!(record.duration, Duration::from_millis(2_000));
539        assert_eq!(record.headline(), "completed in 2.0s");
540        // A rootless run opens its own research line, named after itself.
541        assert_eq!(record.research_line.as_deref(), Some("mos-baseline"));
542        assert!(record.tags.contains(&"run:run_42".to_string()));
543    }
544
545    #[test]
546    fn the_run_tag_is_not_appended_twice() {
547        let mut s = summary();
548        s.tags = vec!["run:run_42".into()];
549        let record = ExperimentRecord::from_run(&s);
550        assert_eq!(record.tags, vec!["run:run_42"]);
551    }
552
553    #[test]
554    fn descending_inherits_the_line_and_computes_the_move() {
555        let parent = ExperimentRecord::from_run(&summary());
556        let mut variant = summary();
557        variant.run_id = "run_43".into();
558        variant.name = "MoS Wider".into();
559        variant.metrics.insert("val_f1".into(), 0.95);
560
561        let child = ExperimentRecord::from_run(&variant).descended_from(&parent);
562        assert_eq!(child.parent.as_deref(), Some("run_42"));
563        assert_eq!(
564            child.research_line.as_deref(),
565            Some("mos-baseline"),
566            "a variant stays in its parent's line, however it is named"
567        );
568        let derivation = child.derivation.as_ref().unwrap();
569        assert_eq!(derivation.from, "run_42");
570        assert_eq!(derivation.to, "run_43");
571        let delta = derivation.metric_delta["val_f1"];
572        assert!((delta.delta - 0.05).abs() < 1e-9);
573    }
574
575    #[test]
576    fn an_amendment_points_at_what_it_amends() {
577        let amendment =
578            ExperimentRecord::amendment("amend_1", "run_42", "the val split was leaking");
579        assert_eq!(amendment.kind, RecordKind::Amendment);
580        assert_eq!(amendment.amends.as_deref(), Some("run_42"));
581        assert!(amendment.has_conclusion());
582        let json = serde_json::to_string(&amendment).unwrap();
583        assert!(json.contains("\"kind\":\"amendment\""), "{json}");
584    }
585
586    #[test]
587    fn slugs_are_stable_and_never_empty() {
588        assert_eq!(slugify("MoS Baseline!"), "mos-baseline");
589        assert_eq!(slugify("mos_baseline v2"), "mos-baseline-v2");
590        assert_eq!(slugify("  spaced  out  "), "spaced-out");
591        assert_eq!(slugify("已经"), "已经");
592        assert_eq!(slugify("!!!"), "unnamed");
593        assert_eq!(slugify(""), "unnamed");
594    }
595}