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 {}