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}