1use 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
19pub const RECORD_SCHEMA_VERSION: u32 = 2;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25#[non_exhaustive]
26pub enum RecordKind {
27 #[default]
29 Experiment,
30 Amendment,
34 #[serde(other)]
36 Other,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct Embedding {
44 pub embedder_id: String,
47 pub vector: Vec<f32>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ExperimentRecord {
54 pub id: String,
56 pub name: String,
59 pub hypothesis: Option<String>,
61 pub pipeline_summary: String,
63 pub params: BTreeMap<String, serde_json::Value>,
65 pub metrics: BTreeMap<String, f64>,
67 pub timestamp: DateTime<Utc>,
69 pub duration: Duration,
71 pub parent: Option<String>,
74 pub research_line: Option<String>,
77 pub tags: Vec<String>,
80 pub notes: Option<String>,
83
84 #[serde(default = "legacy_schema_version")]
88 pub schema_version: u32,
89 #[serde(default)]
91 pub kind: RecordKind,
92 #[serde(default)]
96 pub run_id: Option<String>,
97 #[serde(default)]
99 pub run_dir: Option<String>,
100 #[serde(default)]
103 pub architecture: Option<ArchitectureFingerprint>,
104 #[serde(default)]
106 pub objective: Option<String>,
107 #[serde(default)]
110 pub conclusion: Option<RunConclusion>,
111 #[serde(default)]
113 pub derivation: Option<DerivationMove>,
114 #[serde(default)]
116 pub git: Option<GitInfo>,
117 #[serde(default)]
119 pub amends: Option<String>,
120 #[serde(default)]
123 pub embedding: Option<Embedding>,
124}
125
126fn legacy_schema_version() -> u32 {
128 1
129}
130
131impl ExperimentRecord {
132 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 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 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 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 pub fn with_extra_metrics(mut self, metrics: impl IntoIterator<Item = (String, f64)>) -> Self {
238 self.metrics.extend(metrics);
239 self
240 }
241
242 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 pub fn with_hypothesis(mut self, h: impl Into<String>) -> Self {
260 self.hypothesis = Some(h.into());
261 self
262 }
263
264 pub fn with_pipeline(mut self, summary: impl Into<String>) -> Self {
266 self.pipeline_summary = summary.into();
267 self
268 }
269
270 pub fn with_params(mut self, params: BTreeMap<String, serde_json::Value>) -> Self {
272 self.params = params;
273 self
274 }
275
276 pub fn with_metrics(mut self, metrics: BTreeMap<String, f64>) -> Self {
278 self.metrics = metrics;
279 self
280 }
281
282 pub fn with_duration(mut self, d: Duration) -> Self {
284 self.duration = d;
285 self
286 }
287
288 pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
291 self.parent = Some(parent.into());
292 self
293 }
294
295 pub fn with_research_line(mut self, line: impl Into<String>) -> Self {
297 self.research_line = Some(line.into());
298 self
299 }
300
301 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
303 self.tags = tags;
304 self
305 }
306
307 pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
309 self.notes = Some(notes.into());
310 self
311 }
312
313 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 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
330pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct ResearchLine {
356 pub name: String,
358 pub experiments: Vec<String>,
360 pub trend: Trend,
362 pub best_metric_value: Option<f64>,
364 pub best_metric_name: Option<String>,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370pub enum Trend {
371 Improving,
373 Plateaued,
375 Declining,
377 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#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct ChangePoint {
395 pub experiment_id: String,
397 pub timestamp: DateTime<Utc>,
399 pub metric_name: String,
401 pub value_before: f64,
403 pub value_after: f64,
405 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 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 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 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 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}