Skip to main content

somatize_tree/
snapshot.rs

1//! What a graph was, at one commit. The probe's answer, typed.
2//!
3//! Nothing here holds a graph — a graph is Python and lives for the length of a
4//! subprocess. What crosses back is this, and the fields are the probe's to
5//! add: `src/soma_tree_probe.py` is the contract, and this is one reader of
6//! it.
7
8use crate::findings::Findings;
9use serde::Deserialize;
10use somatize_store::{Digest, Meta, Store};
11use std::collections::{BTreeMap, HashMap};
12use std::fmt;
13use std::path::Path;
14use std::process::Command;
15
16/// A graph as one checkout had it.
17#[derive(Debug, Deserialize, serde::Serialize)]
18pub struct Snapshot {
19    /// What checkout this was, for saying so afterwards.
20    pub commit: String,
21    /// The `module:function` that built it.
22    pub built_from: String,
23    /// `"sentinel"` when no real input was hashed. Two snapshots are only
24    /// comparable if they were taken the same way: the names come out of the
25    /// snapshot, so one taken with an input against one taken without has
26    /// **everything** moved and nothing saying why.
27    pub input: String,
28    /// What the graph was built against: the interpreter, and the version of
29    /// every distribution it reached for.
30    ///
31    /// The axis git does not cover — a checkout pins its own code and not the
32    /// interpreter outside it — and deliberately **not** part of the recipe a
33    /// snapshot is remembered under. Two probes months apart are meant to
34    /// disagree here out loud.
35    #[serde(default)]
36    pub environment: BTreeMap<String, String>,
37    /// `foreseen.snapshot`'s own answer, carried **opaque**.
38    ///
39    /// Never read on this side and never reshaped: what a name is made of is
40    /// the model's business, and a reader here that understood its insides
41    /// would be a second model with a delay on it.
42    pub snapshot: serde_json::Value,
43    /// The class behind each node: where it lives and what it says.
44    ///
45    /// Read while the graph existed, because a snapshot outlives the process
46    /// that made it — and the checkout it was read from is a worktree that was
47    /// removed minutes later.
48    #[serde(default)]
49    pub code: BTreeMap<String, Written>,
50    /// What each node is made of inside: `{node: [piece, ...]}`.
51    ///
52    /// A node is a box and what it holds is usually what the experiment is
53    /// about — `Pure` is a wrapper and the router inside it is the piece — so
54    /// drawing the box and saying nothing about the inside draws the wrapper.
55    ///
56    /// Read without running anything, so it is the **declared** composition:
57    /// what `__init__` built. `somatize.torch.architecture` answers better —
58    /// it sees what is not a module — and executes the graph to do it, which
59    /// is what this side never does. Opaque, like the rest.
60    #[serde(default)]
61    pub inside: serde_json::Value,
62    /// What **files** each node is made of, and where the count stops.
63    ///
64    /// `code` shows one class, which is what somebody clicking a node wants.
65    /// But a network is often written across four modules joined in an
66    /// `__init__`, and `inspect.getsourcefile` knows only one of the four.
67    ///
68    /// Not a second model of what depends on what: it is the transitive closure
69    /// soma's fingerprint already walked in order to hash it, said out loud, so
70    /// it moves when what goes into a fingerprint moves.
71    ///
72    /// **No source inside**, on purpose: forty commits of whole files are
73    /// nearly all of the answer and none of it read. What is here are paths,
74    /// and the content is asked for by its own when somebody opens one.
75    #[serde(default)]
76    pub reaches: serde_json::Value,
77    /// The orthogonal facts of a graph beside what each node computes: who
78    /// implements it, where it runs, on which device, what is kept, what is
79    /// frozen, and in what order it would run.
80    ///
81    /// Opaque like `snapshot`: the vocabulary is soma's, and all that is needed
82    /// here is that it reaches whoever draws intact.
83    #[serde(default)]
84    pub architecture: serde_json::Value,
85    /// The code that **declares** the graph: the body of `build`, with the
86    /// `>>` and the `|`.
87    ///
88    /// The one part of a graph that cannot be read node by node — each class
89    /// says what it does and none says how they connect — so without it the
90    /// topology is only ever seen drawn and never written.
91    ///
92    /// `None` when there is no source to read, which is the absence
93    /// `UNVERSIONED` names a level below.
94    #[serde(default)]
95    pub declaring: Option<Written>,
96    /// The nodes named by the content of their items, which nobody has before
97    /// a run. Carried so a report can say *cannot tell* out loud.
98    #[serde(default)]
99    pub mapped: Vec<String>,
100    /// What would not have to run at all, because something under it is kept.
101    /// Empty without a real store, and that is not the same as "nothing".
102    #[serde(default)]
103    pub unneeded: Vec<String>,
104}
105
106impl Snapshot {
107    /// What each node's answer will be called, `{node: key}`.
108    ///
109    /// Reading inside the opaque `snapshot` is the one thing this side does not
110    /// do — what a name is made of is the model's business — and this is not
111    /// that: using a name **as a name**, to look it up in a store, is what the
112    /// model publishes it for. Decomposing a key to get something out of it
113    /// would be the other thing, and it would live in `foreseen`.
114    ///
115    /// Nodes are missing and it is not an oversight: a `.mapped()` is named by
116    /// the content of its items, which nobody has before a run. That absence
117    /// reads *cannot tell* and never *no data*.
118    pub fn names(&self) -> BTreeMap<String, String> {
119        read_map(&self.snapshot, "names")
120    }
121
122    /// What version of the code each node had, `{node: fingerprint}`.
123    ///
124    /// The side of attribution that survives what the other does not: a key is
125    /// computed against the probing interpreter's environment, so probing a
126    /// three-month-old commit today gives keys matching nothing kept then,
127    /// while the fingerprint was written beside the value by whoever ran.
128    pub fn fingerprints(&self) -> BTreeMap<String, String> {
129        read_map(&self.snapshot, "fingerprints")
130    }
131
132    /// What was built differently around the two of them: name, before, after.
133    ///
134    /// Usually nothing, because two commits probed in one sitting share an
135    /// interpreter. It is a cached probe from months ago against a fresh one
136    /// that answers something here — which is the whole reason the environment
137    /// is left out of what a snapshot is remembered under.
138    pub fn drifted_from<'a>(&'a self, other: &'a Self) -> Vec<(&'a str, String, String)> {
139        let absent = "—".to_string();
140        let mut said: Vec<(&str, String, String)> = Vec::new();
141        for name in self.environment.keys().chain(other.environment.keys()) {
142            let (was, is) = (self.environment.get(name), other.environment.get(name));
143            if was != is && !said.iter().any(|(said, _, _)| said == name) {
144                said.push((
145                    name.as_str(),
146                    was.cloned().unwrap_or(absent.clone()),
147                    is.cloned().unwrap_or(absent.clone()),
148                ));
149            }
150        }
151        said
152    }
153}
154
155/// A `{text: text}` from inside the model's answer, or nothing.
156///
157/// Nothing and not a failure: an old probe, kept before the model published
158/// this field, is still a good answer to everything else. Falling over would
159/// throw away an investigation's record for a function added afterwards.
160fn read_map(said: &serde_json::Value, what: &str) -> BTreeMap<String, String> {
161    said.get(what)
162        .and_then(|found| found.as_object())
163        .map(|found| {
164            found
165                .iter()
166                .filter_map(|(node, told)| Some((node.clone(), told.as_str()?.to_string())))
167                .collect()
168        })
169        .unwrap_or_default()
170}
171
172/// One node's class, as it was at that commit.
173#[derive(Debug, Deserialize, serde::Serialize)]
174pub struct Written {
175    /// Relative to the checkout. `None` for a class with no file behind it.
176    pub file: Option<String>,
177    /// Where the class starts, so an editor can open at it.
178    pub line: u32,
179    /// `None` when it is long enough that reading it is opening a file, not
180    /// glancing at a panel.
181    pub source: Option<String>,
182    pub lines: u32,
183}
184
185/// Everything a probe needs that is the same for every commit it is asked
186/// about. Held together so a call says only what varies: the checkout.
187pub struct Probing<'a> {
188    pub python: &'a Path,
189    pub probe: &'a Path,
190    pub build: &'a str,
191    /// Handed to the probe so it can say what is already computed. Not the
192    /// store snapshots are remembered in, though it is usually the same one.
193    pub store: Option<&'a Path>,
194    pub given: Option<&'a Path>,
195    /// What identifies this probing, everything but the commit: the build, the
196    /// input, and **the probe's own source**.
197    ///
198    /// That last is not belt and braces. A snapshot is a pure function of a
199    /// commit only *given a fixed probe*, so the day `declared` learned to read
200    /// an object's attributes, every snapshot taken before it became wrong — in
201    /// exactly the way this tool exists to catch.
202    pub recipe: Digest,
203}
204
205impl Probing<'_> {
206    /// The name this checkout's snapshot is kept under. Content-addressed and
207    /// immutable: a commit does not change, so neither does the answer.
208    pub fn named(&self, commit: &str) -> String {
209        format!("snapshot:{commit}:{}", self.recipe)
210    }
211
212    /// What was already probed for this commit, without touching a checkout.
213    ///
214    /// Its own method because a walk asks this of **every** commit first and
215    /// only then lays out the ones nobody has an answer for. On a line of
216    /// exploration that has been looked at once, that is no worktrees at all.
217    pub fn recalled(&self, kept: &dyn Store, commit: &str) -> Option<Snapshot> {
218        match recall(kept, &self.named(commit)) {
219            Ok(snapshot) => snapshot,
220            Err(why) => {
221                eprintln!("what was already probed could not be looked up: {why}");
222                None
223            }
224        }
225    }
226
227    /// The snapshot for this checkout, from the store if it is there.
228    ///
229    /// A store that cannot answer is **not** the end of it, exactly as a keeper
230    /// that cannot answer is not the end of a run: the probe is asked instead
231    /// and the trouble is said out loud. A cache gone cold is slow; a cache that
232    /// stops the tool is broken.
233    pub fn remembered(
234        &self,
235        kept: &dyn Store,
236        working: &Path,
237        commit: &str,
238    ) -> Result<Snapshot, Trouble> {
239        let name = self.named(commit);
240        match recall(kept, &name) {
241            Ok(Some(snapshot)) => return Ok(snapshot),
242            Ok(None) => {}
243            Err(why) => eprintln!("what was already probed could not be looked up: {why}"),
244        }
245
246        let (snapshot, bytes) = self.taken(working, commit)?;
247        if let Err(why) = keep(kept, &name, &bytes, commit, self.build) {
248            eprintln!("this probe could not be kept for next time: {why}");
249        }
250        Ok(snapshot)
251    }
252
253    /// What the edit did, for each of these pairs of `(older, newer)`.
254    ///
255    /// Pairs and not an ordered list, because **a step is an edge**: two
256    /// entries next to each other in a walk of three branches are two different
257    /// lines of exploration, and comparing them would answer confidently about
258    /// an edit nobody made.
259    ///
260    /// One subprocess for the whole walk — comparing needs no checkout and no
261    /// graph, only the model and the snapshots. The model is
262    /// `somatize.foreseen`'s and nothing here decides what a finding means.
263    pub fn compared(
264        &self,
265        taken: &HashMap<&str, Snapshot>,
266        pairs: &[(String, String)],
267    ) -> Result<Vec<Findings>, Trouble> {
268        if pairs.is_empty() {
269            return Ok(Vec::new());
270        }
271        let garbled = |why: serde_json::Error| Trouble::Garbled {
272            commit: "comparing".into(),
273            why: why.to_string(),
274        };
275        let asked = serde_json::json!({"snapshots": taken, "pairs": pairs})
276            .to_string()
277            .into_bytes();
278        let written = tempfile::tempdir().map_err(|why| Trouble::Garbled {
279            commit: "comparing".into(),
280            why: why.to_string(),
281        })?;
282        let at = written.path().join("asked.json");
283        std::fs::write(&at, &asked).map_err(|why| Trouble::Garbled {
284            commit: "comparing".into(),
285            why: why.to_string(),
286        })?;
287
288        let said = Command::new(self.python)
289            .arg(self.probe)
290            .arg("--compare")
291            .arg(&at)
292            .output()
293            .map_err(|why| Trouble::Unreachable {
294                python: self.python.display().to_string(),
295                why: why.to_string(),
296            })?;
297        if !said.status.success() {
298            return Err(Trouble::Refused {
299                commit: "comparing".into(),
300                said: String::from_utf8_lossy(&said.stderr).trim().to_string(),
301            });
302        }
303        serde_json::from_slice(&said.stdout).map_err(garbled)
304    }
305
306    /// Whether an edit survives: it parses, a linter is quiet, the graph still
307    /// builds, and the node runs on what its predecessors left in the store.
308    ///
309    /// Asked in a checkout that is **the same tree a fork would commit**, so a
310    /// green light is about the thing that would land and not about something
311    /// near it.
312    pub fn checked(&self, working: &Path, node: &str) -> Result<serde_json::Value, Trouble> {
313        let mut asking = Command::new(self.python);
314        asking
315            .arg(self.probe)
316            .arg("--build")
317            .arg(self.build)
318            .arg("--check")
319            .arg(node)
320            .current_dir(working);
321        if let Some(store) = self.store {
322            asking.arg("--store").arg(store);
323        }
324        if let Some(given) = self.given {
325            asking.arg("--input").arg(given);
326        }
327        let said = asking.output().map_err(|why| Trouble::Unreachable {
328            python: self.python.display().to_string(),
329            why: why.to_string(),
330        })?;
331        if !said.status.success() {
332            return Err(Trouble::Refused {
333                commit: node.to_string(),
334                said: String::from_utf8_lossy(&said.stderr).trim().to_string(),
335            });
336        }
337        serde_json::from_slice(&said.stdout).map_err(|why| Trouble::Garbled {
338            commit: node.to_string(),
339            why: why.to_string(),
340        })
341    }
342
343    /// The same source, formatted — or the same source back and a reason.
344    ///
345    /// `ruff format` if this environment has one. Refused rather than
346    /// half-done when it does not: handing back something that looks formatted
347    /// and is not is worse than a button that says it cannot.
348    pub fn prettified(&self, source: &str) -> Result<serde_json::Value, Trouble> {
349        use std::io::Write as _;
350        let mut running = Command::new(self.python)
351            .arg(self.probe)
352            .args(["--build", "x:y", "--format"])
353            .stdin(std::process::Stdio::piped())
354            .stdout(std::process::Stdio::piped())
355            .stderr(std::process::Stdio::piped())
356            .spawn()
357            .map_err(|why| Trouble::Unreachable {
358                python: self.python.display().to_string(),
359                why: why.to_string(),
360            })?;
361        let asked = |why: String| Trouble::Garbled {
362            commit: "formatting".into(),
363            why,
364        };
365        running
366            .stdin
367            .take()
368            .ok_or_else(|| asked("no stdin".into()))?
369            .write_all(source.as_bytes())
370            .map_err(|why| asked(why.to_string()))?;
371        let said = running
372            .wait_with_output()
373            .map_err(|why| asked(why.to_string()))?;
374        serde_json::from_slice(&said.stdout).map_err(|why| asked(why.to_string()))
375    }
376
377    /// Runs the probe in a checkout and reads what it wrote, with the bytes it
378    /// wrote — which are what gets kept.
379    ///
380    /// A subprocess and not a library call, and it is why this tool has two
381    /// languages in it: the graph only exists once the checkout's own code has
382    /// been imported and run, against the soma *that* checkout pins. Reaching
383    /// into it from here would run one version of the engine over another
384    /// version's declarations.
385    pub fn taken(&self, working: &Path, commit: &str) -> Result<(Snapshot, Vec<u8>), Trouble> {
386        let mut asking = Command::new(self.python);
387        asking
388            .arg(self.probe)
389            .arg("--build")
390            .arg(self.build)
391            .arg("--commit")
392            .arg(commit)
393            .current_dir(working);
394        if let Some(store) = self.store {
395            asking.arg("--store").arg(store);
396        }
397        if let Some(given) = self.given {
398            asking.arg("--input").arg(given);
399        }
400        let said = asking.output().map_err(|why| Trouble::Unreachable {
401            python: self.python.display().to_string(),
402            why: why.to_string(),
403        })?;
404        if !said.status.success() {
405            return Err(Trouble::Refused {
406                commit: commit.to_string(),
407                said: String::from_utf8_lossy(&said.stderr).trim().to_string(),
408            });
409        }
410        let snapshot = serde_json::from_slice(&said.stdout).map_err(|why| Trouble::Garbled {
411            commit: commit.to_string(),
412            why: why.to_string(),
413        })?;
414        Ok((snapshot, said.stdout))
415    }
416}
417
418/// What is kept under that name, if anything readable is.
419fn recall(kept: &dyn Store, name: &str) -> Result<Option<Snapshot>, String> {
420    let Some(bound) = kept.resolve(name).map_err(|why| why.to_string())? else {
421        return Ok(None);
422    };
423    let Some(bytes) = kept.get(&bound.digest).map_err(|why| why.to_string())? else {
424        // Named, but the blob is not there. That is what a half-copied store
425        // looks like and not a reason to stop: the probe still works.
426        return Ok(None);
427    };
428    serde_json::from_slice(&bytes)
429        .map(Some)
430        .map_err(|why| why.to_string())
431}
432
433/// Puts a probe's answer where the next one will find it.
434fn keep(
435    kept: &dyn Store,
436    name: &str,
437    bytes: &[u8],
438    commit: &str,
439    build: &str,
440) -> Result<(), String> {
441    let digest = kept.put(bytes).map_err(|why| why.to_string())?;
442    // Said beside it so that a scan of the store reads as something. The
443    // records are the truth, and any index over them is built from these.
444    let meta: Meta = vec![
445        ("what".into(), "snapshot".into()),
446        ("commit".into(), commit.into()),
447        ("built_from".into(), build.into()),
448    ];
449    kept.bind(name, &digest, meta)
450        .map_err(|why| why.to_string())
451}
452
453/// What can go wrong between here and a graph.
454#[derive(Debug)]
455pub enum Trouble {
456    Unreachable { python: String, why: String },
457    Refused { commit: String, said: String },
458    Garbled { commit: String, why: String },
459}
460
461impl fmt::Display for Trouble {
462    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
463        match self {
464            // The commonest failure by far, and worth naming precisely: the
465            // interpreter that can import somatize is rarely the one on PATH.
466            Self::Unreachable { python, why } => {
467                write!(f, "`{python}` could not be run: {why}")
468            }
469            Self::Refused { commit, said } => {
470                write!(f, "building the graph at {commit} failed:\n{said}")
471            }
472            Self::Garbled { commit, why } => {
473                write!(f, "the probe at {commit} said something unreadable: {why}")
474            }
475        }
476    }
477}
478
479impl std::error::Error for Trouble {}