Skip to main content

somatize_tree/
bench.rs

1//! Everything a command needs standing up before it can ask anything.
2//!
3//! In the library and not in the binary because a request handler needs exactly
4//! the same things a terminal command does, and two ways of finding the probe
5//! or of deciding where answers are remembered would be two tools wearing one
6//! name.
7
8use crate::journal::Journal;
9use crate::moves::Moves;
10use crate::reasoning::{Reasoning, reasoned};
11use crate::revision::{self, Worktree};
12use crate::snapshot::{Probing, Snapshot};
13use crate::walk::{self, Walk};
14use serde::Deserialize;
15use somatize_store::{Digest, Local};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19/// `soma-tree.toml`, at the root of the repository being explored.
20#[derive(Deserialize)]
21pub struct Config {
22    /// `module:function` — takes nothing, returns a `Graph`.
23    ///
24    /// Optional, and not as a convenience: **reading an investigation's
25    /// reasoning should not require knowing how to build its graph**. Finished
26    /// work — a paper, a repository nobody runs, one from before soma — has
27    /// reasoning worth reading and may have nothing to probe. Without it, what
28    /// needs a probe says so and the rest works.
29    #[serde(default)]
30    pub build: Option<String>,
31    /// The interpreter that can import somatize. Rarely the one on `PATH`.
32    #[serde(default = "python_on_path")]
33    pub python: PathBuf,
34    /// What this investigation is called, so several can share one store
35    /// without seeing each other. Defaults to the repository's own name.
36    ///
37    /// **It is in the name records are bound under**, which is the one part of
38    /// this that cannot be changed later without moving somebody's directories.
39    pub tree: Option<String>,
40    /// Which way is better: `min` for a loss, `max` for an accuracy.
41    ///
42    /// Declared here because it is **not in the store**: the direction lives in
43    /// the `Goal` handed to a sampler and is written in no record. Without it
44    /// trials are shown by their range, which is true anyway.
45    pub goal: Option<String>,
46}
47
48fn python_on_path() -> PathBuf {
49    PathBuf::from("python3")
50}
51
52impl Config {
53    /// How to build the graph, or why it cannot be.
54    ///
55    /// The message is for whoever is about to probe and not for whoever reads:
56    /// somebody looking at the reasoning never gets here.
57    pub fn building(&self) -> Result<&str, String> {
58        self.build.as_deref().ok_or_else(|| {
59            format!(
60                "{} does not say what to build, so there is no graph to probe.\n\n    \
61                 build = \"experiments.encoder:build\"\n\n\
62                 The reasoning and the journal read the same without it.",
63                "soma-tree.toml"
64            )
65        })
66    }
67
68    /// Read from the repository and not from the checkout: how an experiment
69    /// is built is a fact about the project now, and reading it out of each
70    /// commit would leave one predating the file unprobeable.
71    ///
72    /// **Not being there is not a failure**, for the same reason `build` is
73    /// optional: a repository from before soma has a reasoning worth reading
74    /// and nothing to probe, and it has no `soma-tree.toml` either. What needs
75    /// a probe still says so, through [`building`](Self::building).
76    pub fn read(repo: &Path) -> Result<Self, String> {
77        let at = repo.join("soma-tree.toml");
78        let text = match std::fs::read_to_string(&at) {
79            Ok(text) => text,
80            Err(why) if why.kind() == std::io::ErrorKind::NotFound => String::new(),
81            Err(why) => return Err(format!("{} could not be read: {why}", at.display())),
82        };
83        toml::from_str(&text).map_err(|why| format!("{} is not readable: {why}", at.display()))
84    }
85
86    /// Which way is better, if it was declared. What is not understood is
87    /// refused rather than read as *not declared*: a typo in `goal` would stop
88    /// saying which was best with nothing saying why.
89    pub fn towards(&self) -> Result<Option<crate::trials::Goal>, String> {
90        match self.goal.as_deref() {
91            None => Ok(None),
92            Some(said) => crate::trials::Goal::read(said).map(Some).ok_or_else(|| {
93                format!("`goal = \"{said}\"` does not say which way: `min` for a loss, `max` for an accuracy")
94            }),
95        }
96    }
97
98    pub fn tree(&self, repo: &Path) -> String {
99        self.tree.clone().unwrap_or_else(|| {
100            repo.file_name()
101                .map(|name| name.to_string_lossy().into_owned())
102                .unwrap_or_else(|| "tree".to_string())
103        })
104    }
105
106    /// The interpreter, made absolute against the repo so that a relative
107    /// `.venv/bin/python` still resolves once the probe runs in a worktree
108    /// somewhere else entirely.
109    pub fn interpreter(&self, repo: &Path) -> PathBuf {
110        match self.python.is_absolute() || self.python.components().count() == 1 {
111            true => self.python.clone(),
112            false => repo.join(&self.python),
113        }
114    }
115}
116
117/// It owns what [`Probing`] borrows, which is the whole reason it is a struct:
118/// the interpreter's path and the build's name outlive every commit asked
119/// about, and threading them through each call said nothing.
120pub struct Bench {
121    pub repo: PathBuf,
122    pub config: Config,
123    pub remembering: Local,
124    python: PathBuf,
125    probe: PathBuf,
126    recipe: Digest,
127}
128
129impl Bench {
130    pub fn set_up(
131        repo: &Path,
132        store: Option<&Path>,
133        given: Option<&Path>,
134    ) -> Result<Self, Box<dyn std::error::Error>> {
135        let repo = repo.canonicalize()?;
136        let config = Config::read(&repo)?;
137        let python = config.interpreter(&repo);
138        // The recipe identifies the probe and what it builds. With nothing to
139        // build there is no probing, and an empty recipe is never used: it is
140        // the path by which reasoning is read with nothing runnable.
141        let recipe = match config.build.as_deref() {
142            Some(build) => recipe(build, given)?,
143            None => Digest::of(b""),
144        };
145        // Laid down whatever the config says: `prettified` and `compared` ask
146        // the probe without needing a `build`, and this costs one write, once
147        // ever, into a directory `Local::at` below already has to be able to
148        // create `blobs/` in.
149        let probe = probe_laid_down(&where_probes_are_remembered())?;
150        let remembering = Local::at(match store {
151            Some(store) => store.to_path_buf(),
152            None => where_probes_are_remembered(),
153        })?;
154        Ok(Self {
155            repo,
156            config,
157            remembering,
158            python,
159            probe,
160            recipe,
161        })
162    }
163
164    pub fn probing<'a>(&'a self, store: Option<&'a Path>, given: Option<&'a Path>) -> Probing<'a> {
165        Probing {
166            python: &self.python,
167            probe: &self.probe,
168            build: self.config.build.as_deref().unwrap_or_default(),
169            store,
170            given,
171            recipe: self.recipe.clone(),
172        }
173    }
174
175    pub fn journal(&self) -> Journal<'_> {
176        Journal::of(self.config.tree(&self.repo), &self.remembering)
177    }
178
179    pub fn moves(&self) -> Moves<'_> {
180        Moves::of(self.config.tree(&self.repo), &self.remembering)
181    }
182
183    /// The reasoning read back, derived. Fails if the store cannot be read.
184    pub fn reasoning(&self) -> Result<Reasoning, crate::moves::Trouble> {
185        reasoned(&self.config.tree(&self.repo), &self.remembering)
186    }
187}
188
189/// A whole line of exploration, probed and compared and judged.
190///
191/// The one entry point both a terminal and a request handler use, so neither
192/// can drift from the other about what an investigation contains.
193/// The bench is handed in and not built here.
194///
195/// It used to take the paths and stand one up of its own, which quietly made
196/// this the second place that reads `soma-tree.toml` — so a name said on the
197/// command line reached the journal and not the walk, and a verdict written
198/// one moment was invisible the next with nothing saying why.
199pub fn walking(
200    bench: &Bench,
201    store: Option<&Path>,
202    given: Option<&Path>,
203    range: &str,
204    most: usize,
205) -> Result<Walk, Box<dyn std::error::Error>> {
206    let probing = bench.probing(store, given);
207    let shown = revision::commits_in(&bench.repo, range, most)?;
208    if shown.is_empty() {
209        return Err(format!("`{range}` names no commits").into());
210    }
211    // The range says what to show; every line in it needs the commit under it
212    // to be compared against, and three branches have three of those.
213    let mut commits = shown.clone();
214    commits.extend(revision::beneath(&bench.repo, &shown));
215
216    // With nothing to build nothing is probed, and that is not an error: the
217    // history, the journal, the trials and the reasoning read the same. What
218    // is missing is what each edit did.
219    let known = match bench.config.build {
220        Some(_) => probed(bench, &probing, &commits)?,
221        None => HashMap::new(),
222    };
223    walk::walked(
224        &bench.repo,
225        walk::Remembered {
226            tree: &bench.config.tree(&bench.repo),
227            kept: &bench.remembering,
228            goal: bench.config.towards()?,
229        },
230        &probing,
231        &shown,
232        &commits,
233        &known,
234    )
235}
236
237/// How many probes run at once.
238///
239/// Not the core count, which is the number this looks like it should be. What a
240/// probe holds is an interpreter with the checkout's own `somatize` imported,
241/// and that is torch: a quarter of a gigabyte, each. So the bound is memory and
242/// it does not grow with the machine — twenty cores would ask for five
243/// gigabytes to walk one line, and `cargo test` runs twenty of *those* at once.
244const AT_ONCE: usize = 4;
245
246/// A snapshot for every one of these commits.
247///
248/// Asked of the store first and of a checkout second. On a line somebody has
249/// already looked at, this lays out no worktrees at all — which is the whole
250/// reason a walk of ten commits is affordable.
251pub fn probed<'a>(
252    bench: &Bench,
253    probing: &Probing,
254    commits: &'a [String],
255) -> Result<HashMap<&'a str, Snapshot>, Box<dyn std::error::Error>> {
256    // Cut here, which is where the reason is known. Letting it through sent an
257    // empty build to Python and came back `one of --build or --compare`, which
258    // tells nobody that what is missing is a line in their soma-tree.toml.
259    bench.config.building()?;
260    let mut known: HashMap<&str, Snapshot> = HashMap::new();
261    for commit in commits {
262        if let Some(snapshot) = probing.recalled(&bench.remembering, commit) {
263            known.insert(commit, snapshot);
264        }
265    }
266    let missing: Vec<&String> = commits
267        .iter()
268        .filter(|commit| !known.contains_key(commit.as_str()))
269        .collect();
270    if missing.is_empty() {
271        return Ok(known);
272    }
273
274    eprintln!(
275        "probing {} of {} commits; {} were already known",
276        missing.len(),
277        commits.len(),
278        commits.len() - missing.len(),
279    );
280    let laid_out = tempfile::tempdir()?;
281    let trees: Vec<Worktree> = missing
282        .iter()
283        .enumerate()
284        .map(|(n, commit)| Worktree::of(&bench.repo, commit, laid_out.path(), &n.to_string()))
285        .collect::<Result<_, _>>()?;
286
287    // Threads and not tasks: what this waits on is a Python interpreter
288    // importing torch, which is somebody else's CPU and not an idle socket.
289    // There is nothing here for an executor to interleave.
290    //
291    // A pool of `AT_ONCE` and not a thread per commit, and what the pool shares
292    // is an index rather than a chunk each: a commit that takes a minute holds
293    // up nothing behind it. Answers come back by position, because what names a
294    // snapshot here is the revspec that was asked for and a probe only knows
295    // the hash it resolved to.
296    use std::sync::atomic::{AtomicUsize, Ordering};
297    // Both outlive the scope on purpose: a worker borrows them, so declaring
298    // them inside would be lending what is about to go out of scope.
299    let next = AtomicUsize::new(0);
300    let (tell, heard) = std::sync::mpsc::channel();
301    let fresh = std::thread::scope(|scope| {
302        let remembering = &bench.remembering;
303        for _ in 0..AT_ONCE.min(trees.len()) {
304            let (tell, next, trees) = (tell.clone(), &next, &trees);
305            scope.spawn(move || {
306                loop {
307                    let n = next.fetch_add(1, Ordering::Relaxed);
308                    let Some(tree) = trees.get(n) else { break };
309                    let said = probing.remembered(remembering, tree.path(), tree.commit());
310                    // The receiver is this scope, which outlives every worker.
311                    let _ = tell.send((n, said));
312                }
313            });
314        }
315        // Or the collector waits on a sender nobody is holding.
316        drop(tell);
317        let mut fresh: Vec<Option<_>> = trees.iter().map(|_| None).collect();
318        for (n, said) in heard {
319            fresh[n] = Some(said);
320        }
321        fresh
322    });
323    let fresh = fresh
324        .into_iter()
325        .map(|said| said.expect("every commit laid out was probed"));
326    for (commit, snapshot) in missing.iter().zip(fresh) {
327        known.insert(commit, snapshot?);
328    }
329    Ok(known)
330}
331
332/// Where a probe's answer is kept when nobody said where.
333///
334/// A cache and not a store of record: it holds only what can be worked out
335/// again from a commit, so deleting it costs time and nothing else.
336/// The probe itself, compiled in.
337///
338/// **Not found at run time, because there was nowhere honest to look.** It used
339/// to be sought beside the binary and then, failing that, at the
340/// `CARGO_MANIFEST_DIR` of whoever compiled it — so a `cargo install` left a
341/// binary depending on a registry checkout it does not own, and copying the
342/// file beside the executable was a step nobody performs.
343///
344/// And it does not go in the wheel either, which is the other tempting answer:
345/// the probe belongs to **this tool** while `somatize` belongs to the checkout
346/// being explored, so `python -m somatize.tree.probe` would run the explored
347/// project's probe against its own graph and quietly answer a different
348/// question.
349const PROBE: &str = include_str!("soma_tree_probe.py");
350
351pub fn where_probes_are_remembered() -> PathBuf {
352    std::env::var_os("XDG_CACHE_HOME")
353        .map(PathBuf::from)
354        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache")))
355        .unwrap_or_else(std::env::temp_dir)
356        .join("somatize-tree")
357}
358
359/// What a remembered answer depends on, other than the commit.
360///
361/// The probe's **own source** is in here, and that is the point: a snapshot is
362/// a pure function of a commit only for a fixed probe.
363fn recipe(build: &str, given: Option<&Path>) -> Result<Digest, String> {
364    let input = match given {
365        Some(given) => std::fs::read(given).map_err(|why| format!("{}: {why}", given.display()))?,
366        None => b"sentinel".to_vec(),
367    };
368    Ok(Digest::of(
369        &[PROBE.as_bytes(), build.as_bytes(), &input[..]].concat(),
370    ))
371}
372
373/// The compiled-in probe, on disk under `cache`, because `python` is handed a
374/// path.
375///
376/// Named by its own digest under the cache this tool already owns, so it is
377/// written once and can never be stale: a probe that changed is a different
378/// name and the old file is simply not asked for. Three of the four ways the
379/// probe is called pass it as `argv[1]`, and the fourth is already using stdin
380/// for the source it formats, so there is no reading it from a pipe.
381///
382/// Keeping the name in the file keeps it in a traceback, where somebody
383/// debugging their own `build()` will read it — and a file under a cache is
384/// still there when they go and look, which a temporary directory is not.
385///
386/// The cache is a parameter and not read from the environment in here: it is
387/// **this tool's** and never the store `--store` points at, and saying which of
388/// the two it is at the call site is the whole difference.
389pub fn probe_laid_down(cache: &Path) -> Result<PathBuf, String> {
390    let digest = Digest::of(PROBE.as_bytes());
391    let hex = digest
392        .as_str()
393        .rsplit(':')
394        .next()
395        .unwrap_or(digest.as_str());
396    let at = cache.join("probe");
397    let laid = at.join(format!("soma_tree_probe-{hex}.py"));
398    if laid.exists() {
399        return Ok(laid);
400    }
401    std::fs::create_dir_all(&at).map_err(|why| format!("{}: {why}", at.display()))?;
402    // Written beside and moved into place: two walks starting at once must not
403    // hand `python` a file that is half there.
404    let landing = at.join(format!("soma_tree_probe-{hex}.{}.py", std::process::id()));
405    std::fs::write(&landing, PROBE).map_err(|why| format!("{}: {why}", landing.display()))?;
406    std::fs::rename(&landing, &laid).map_err(|why| format!("{}: {why}", laid.display()))?;
407    Ok(laid)
408}