Skip to main content

somatize_runtime/tracking/
head.rs

1//! `.soma/HEAD` — which run the next one descends from.
2//!
3//! An experiment pool is only as useful as its edges, and an edge needs
4//! a parent. Soma resolves one in four steps, most explicit first:
5//!
6//! 1. the `parent=` argument the caller passed,
7//! 2. `$SOMA_PARENT_RUN` (for schedulers and CI that fan work out),
8//! 3. `.soma/HEAD`, advanced automatically by the last **successful**
9//!    run in this root,
10//! 4. nothing — this run starts a new line.
11//!
12//! HEAD advances only on success: a crashed attempt must not become the
13//! parent of everything that follows. To branch off an older run,
14//! rewind with `soma.checkout(run_id)`.
15//!
16//! **Inferring the parent from timestamps is deliberately not done.**
17//! "The run before this one" is not the same claim as "the run this one
18//! was derived from", and a single false edge poisons every delta
19//! computed downstream of it. An absent parent is recoverable; a wrong
20//! one is not.
21
22use somatize_core::error::{Result, SomaError};
23use std::fs;
24use std::path::{Path, PathBuf};
25
26/// Environment override for the parent run id.
27pub const PARENT_ENV: &str = "SOMA_PARENT_RUN";
28
29/// Path of the HEAD file for a tracking root (`.soma/HEAD`).
30pub fn head_path(root: impl AsRef<Path>) -> PathBuf {
31    root.as_ref().join("HEAD")
32}
33
34/// The run id in `.soma/HEAD`, if any. An unreadable, empty or
35/// whitespace-only HEAD reads as absent — never as an error, because a
36/// broken pointer must not stop a run from starting.
37pub fn read_head(root: impl AsRef<Path>) -> Option<String> {
38    let text = fs::read_to_string(head_path(root)).ok()?;
39    let trimmed = text.trim();
40    (!trimmed.is_empty()).then(|| trimmed.to_string())
41}
42
43/// Point HEAD at `run_id`, atomically (write to a temp file, then
44/// rename) so a crash mid-write leaves the previous pointer intact
45/// rather than a truncated one.
46pub fn write_head(root: impl AsRef<Path>, run_id: &str) -> Result<()> {
47    let root = root.as_ref();
48    fs::create_dir_all(root)?;
49    let final_path = head_path(root);
50    let tmp = final_path.with_extension("tmp");
51    fs::write(&tmp, format!("{run_id}\n"))?;
52    fs::rename(&tmp, &final_path)?;
53    Ok(())
54}
55
56/// Detach HEAD: the next run starts a new line. Absent HEAD is fine.
57pub fn clear_head(root: impl AsRef<Path>) -> Result<()> {
58    match fs::remove_file(head_path(root)) {
59        Ok(()) => Ok(()),
60        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
61        Err(e) => Err(SomaError::Io(e)),
62    }
63}
64
65/// Whether `<root>/runs/<run_id>/manifest.json` exists.
66pub fn run_exists(root: impl AsRef<Path>, run_id: &str) -> bool {
67    root.as_ref()
68        .join("runs")
69        .join(run_id)
70        .join("manifest.json")
71        .exists()
72}
73
74/// Point HEAD at an existing run so the next run branches from it.
75///
76/// Errors when the run is unknown to this root: silently accepting a
77/// typo would attach the next experiment to a parent that does not
78/// exist, which is exactly the false edge this module refuses to make.
79pub fn checkout(root: impl AsRef<Path>, run_id: &str) -> Result<()> {
80    let root = root.as_ref();
81    if !run_exists(root, run_id) {
82        return Err(SomaError::Other(format!(
83            "no run '{run_id}' under {}/runs — checkout needs a run that exists",
84            root.display()
85        )));
86    }
87    write_head(root, run_id)
88}
89
90/// Resolve the parent run for a run about to start.
91///
92/// See the module docs for the precedence. Reads the environment and
93/// the filesystem; [`resolve_parent_from`] is the pure core.
94pub fn resolve_parent(root: impl AsRef<Path>, explicit: Option<&str>) -> Option<String> {
95    let root = root.as_ref();
96    let env = std::env::var(PARENT_ENV).ok();
97    resolve_parent_from(explicit, env.as_deref(), || read_head(root))
98}
99
100/// The precedence rule, with its inputs injected — the testable core.
101///
102/// `head` is a closure so the file is only read when the earlier, more
103/// explicit sources came up empty.
104pub fn resolve_parent_from(
105    explicit: Option<&str>,
106    env: Option<&str>,
107    head: impl FnOnce() -> Option<String>,
108) -> Option<String> {
109    let non_empty = |s: &str| {
110        let s = s.trim();
111        (!s.is_empty()).then(|| s.to_string())
112    };
113    explicit
114        .and_then(non_empty)
115        .or_else(|| env.and_then(non_empty))
116        .or_else(head)
117}
118
119/// Advance HEAD after a run finished successfully.
120///
121/// Best-effort: lineage bookkeeping must never fail a training run that
122/// already produced its results. Returns whether HEAD moved.
123pub fn advance_head(root: impl AsRef<Path>, run_id: &str) -> bool {
124    write_head(root, run_id).is_ok()
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use tempfile::TempDir;
131
132    #[test]
133    fn head_roundtrips_and_tolerates_absence() {
134        let root = TempDir::new().unwrap();
135        assert_eq!(read_head(root.path()), None);
136
137        write_head(root.path(), "run_a").unwrap();
138        assert_eq!(read_head(root.path()).as_deref(), Some("run_a"));
139
140        // The trailing newline the writer adds is not part of the id.
141        let raw = fs::read_to_string(head_path(root.path())).unwrap();
142        assert_eq!(raw, "run_a\n");
143
144        write_head(root.path(), "run_b").unwrap();
145        assert_eq!(read_head(root.path()).as_deref(), Some("run_b"));
146
147        clear_head(root.path()).unwrap();
148        assert_eq!(read_head(root.path()), None);
149        // Clearing twice is not an error.
150        clear_head(root.path()).unwrap();
151    }
152
153    #[test]
154    fn a_blank_head_reads_as_no_parent() {
155        let root = TempDir::new().unwrap();
156        fs::write(head_path(root.path()), "   \n").unwrap();
157        assert_eq!(read_head(root.path()), None);
158    }
159
160    #[test]
161    fn write_head_creates_the_root() {
162        let root = TempDir::new().unwrap();
163        let nested = root.path().join("deep").join(".soma");
164        write_head(&nested, "run_x").unwrap();
165        assert_eq!(read_head(&nested).as_deref(), Some("run_x"));
166    }
167
168    #[test]
169    fn precedence_is_explicit_then_env_then_head() {
170        let head = || Some("from_head".to_string());
171        assert_eq!(
172            resolve_parent_from(Some("explicit"), Some("env"), head).as_deref(),
173            Some("explicit")
174        );
175        assert_eq!(
176            resolve_parent_from(None, Some("env"), head).as_deref(),
177            Some("env")
178        );
179        assert_eq!(
180            resolve_parent_from(None, None, head).as_deref(),
181            Some("from_head")
182        );
183        assert_eq!(resolve_parent_from(None, None, || None), None);
184        // Blank strings are absence, not a parent named "".
185        assert_eq!(resolve_parent_from(Some("  "), Some(""), || None), None);
186    }
187
188    #[test]
189    fn head_is_not_read_when_a_parent_is_already_known() {
190        let mut read = false;
191        let head = || {
192            read = true;
193            Some("from_head".to_string())
194        };
195        assert_eq!(
196            resolve_parent_from(Some("explicit"), None, head).as_deref(),
197            Some("explicit")
198        );
199        assert!(!read, "the filesystem is only touched as a last resort");
200    }
201
202    #[test]
203    fn checkout_refuses_a_run_that_does_not_exist() {
204        let root = TempDir::new().unwrap();
205        let err = checkout(root.path(), "typo_run").unwrap_err();
206        assert!(err.to_string().contains("no run 'typo_run'"), "{err}");
207        assert_eq!(read_head(root.path()), None, "HEAD must not move");
208
209        // A run with a manifest is a run.
210        let run_dir = root.path().join("runs").join("run_real");
211        fs::create_dir_all(&run_dir).unwrap();
212        fs::write(run_dir.join("manifest.json"), "{}").unwrap();
213        assert!(run_exists(root.path(), "run_real"));
214        checkout(root.path(), "run_real").unwrap();
215        assert_eq!(read_head(root.path()).as_deref(), Some("run_real"));
216    }
217
218    #[test]
219    fn advancing_head_reports_whether_it_moved() {
220        let root = TempDir::new().unwrap();
221        assert!(advance_head(root.path(), "run_1"));
222        assert_eq!(read_head(root.path()).as_deref(), Some("run_1"));
223    }
224}