spacr.run_journal¶
Workflow inputs and outputs¶
Run History¶
Inspect saved run status, settings, outputs and logs; a completed run does not establish biological validity.
Open: the application’s Help/tools menus.
Inputs and outputs below include conditional alternatives. The guidance and handoff notes say which route applies.
Inputs
Run history and artifacts — Project run records, settings, output paths, artifact provenance, status and logs.
Outputs
Run history and artifacts — Project run records, settings, output paths, artifact provenance, status and logs.
Run journal — reproducibility record for every pipeline invocation.
Every time a spaCR pipeline runs (mask / measure / classify / …),
open_run() writes a timestamped folder under ~/.spacr/runs/
containing everything a reviewer needs to reproduce the result:
~/.spacr/runs/2026-07-23_143507_ab12cd34__mask/
settings.csv # exact settings dict, Key,Value CSV
settings.json # same, JSON (source of truth for machines)
manifest.json # spaCR version, git hash, python, packages,
# torch / cuda / cellpose, start time,
# end time, elapsed, exit status, model hashes
log.txt # tail of ~/.spacr/logs/spacr.log for the run
outputs/ # optional — any pipeline-emitted artifacts
# (masks, DBs, CSVs, plots) copied in
Public API:
from spacr.run_journal import open_run
with open_run("mask", settings) as run:
preprocess_generate_masks(settings)
run.attach_output(Path("/path/to/mask.tif"))
run.set_status("success")
The context manager records start / end timestamps and yields the
Run object, whose dir attribute is the run folder. When
something raises it stamps "status": "failed" (plus the traceback)
into manifest.json and re-raises; no marker file is written.
Consumers of the journal:
spacr repro <run-folder>— replays the run (seespacr.cli_repro).AI Console → “File as issue” — includes the last run’s manifest when present so bug reports are self-contained.
Home screen “Recent runs” list — enumerated from
recent_runs()newest first.
Beside the runs, the journal keeps two records for blinded work.
start_blinding() writes a blinding key (coded names and a shuffled
order) to ~/.spacr/blinding and unblind() logs who opened it and
when. lock_analysis() freezes an analysis plan, hashed and timestamped,
in ~/.spacr/analysis_locks (settings of one or more pipelines, Gate
Editor gating strategies, models and files); every later run of a locked
pipeline on its src is checked against it by
check_analysis_lock(), every model the run records is checked as it is
recorded, and the
verdict is written into that run’s manifest.json under
analysis_lock, with any difference also listed in
provenance_warnings.
Attributes¶
Current on-disk reproducibility-manifest schema. |
Classes¶
A single pipeline invocation's on-disk record. |
Functions¶
|
Check a run's settings against its preregistered analysis lock. |
|
Return the |
|
Delete journalled run folders. Returns |
|
Compare two journalled runs and report exactly what changed. |
|
Capture declared seeds plus already-loaded RNG state fingerprints. |
|
Render |
|
Return a file's SHA-256 — 16 hex chars, 64 if |
|
Return aggregate counts across every stored run. |
|
Read a run's |
|
Freeze an analysis plan before its results are seen. |
|
Open a fresh run journal folder around a pipeline invocation. |
|
Return the |
|
Turn a run reference into a run-folder |
|
Return |
|
Return searchable, dashboard-ready records for all journalled runs. |
|
Shuffle |
|
Open a blinding key, and record who opened it and when. |
|
True when |
Module Contents¶
- class spacr.run_journal.Run[source]¶
A single pipeline invocation’s on-disk record.
Instances are produced by
open_run(). Users don’t construct them directly.- Variables:
app_key – id of the pipeline app that opened the run.
settings – settings dict originally passed to the pipeline.
dir – run folder path (
~/.spacr/runs/<ts>_<uuid>__<app>).start_ts – unix epoch seconds when the run opened.
end_ts – unix epoch seconds when the run closed (set by
open_run()on exit).status –
"running"/"success"/"failed"/"cancelled". The last is a run the user stopped, which is not a run that broke, and the two want different things done next.model_hashes – dict of
{human-name: "filename:sha256-16"}. Populated by callers viarecord_model().model_files – full SHA-256, size, and path records for models.
input_hashes – per-file full SHA-256 input provenance.
output_hashes – per-file full SHA-256 output provenance.
seeds – declared seeds and runtime RNG-state identifiers.
provenance_warnings – non-fatal path/hash failures retained in the manifest instead of being silently discarded.
run_warnings – distinct warning lines emitted by the pipeline.
environment – host, spaCR, Git, and installed-package versions.
stages – consolidated FlowView lifecycle records in execution order.
stdout_path – captured standard-output log path, when one is attached.
error_traceback – formatted exception traceback for failed or cancelled runs; empty for successful runs.
- attach_output(src_path: Any) pathlib.Path | None[source]¶
Copy
src_pathinto the run’soutputs/folder.- Parameters:
src_path – path to a file (or folder) worth preserving for reproducibility.
- Returns:
destination path in the run folder, or
Noneon error.
- hashing_enabled() bool[source]¶
Whether to hash inputs and outputs for this run.
Off unless the settings say otherwise. Hashing every file under every path-valued setting is proportional to the DATA, not to the run: on a plate of raw images it is minutes of reading before the first mask is made, and it happens whether or not anybody will ever compare the digests.
Read from the settings dict, never from QSettings. A
from PySide6 importin a pipeline module makes the package unimportable on a cluster (see the architecture notes), so the GUI reads the preference and passes it down as an ordinary setting, and a headless caller sets the same key.
- record_input(path: Any, *, setting_key: str = '') None[source]¶
Hash a file or directory as an explicit run input.
A no-op unless
hashing_enabled()is true, i.e. unless the settings enablehash_inputs— a default run records nothing here. Directories are recorded as one full SHA-256 record per regular file. Unreadable files are reported inprovenance_warningsand logs.- Parameters:
path – input file or directory.
setting_key – optional setting that referred to
path.
- record_model(name: str, checkpoint_path: Any) None[source]¶
Fingerprint
checkpoint_pathand remember it undername.- Parameters:
name – human-readable key under which to record the model.
checkpoint_path – model checkpoint file to fingerprint.
Records
"<filename>:<digest>"inmodel_hashes. An unreadable checkpoint is only logged and leaves no entry at all; any other failure is also appended toprovenance_warnings, so it reachesmanifest.json— model logging must never itself fail a run. When an analysis lock applies to the run, the model is also checked against it (seelock_analysis()), and the run’s verdict is updated.
- record_output(path: Any, *, setting_key: str = '') None[source]¶
Hash a file or directory as an explicit run output.
Like
record_input(), a no-op unlesshashing_enabled()is true, i.e. unless the settings enablehash_inputs.- Parameters:
path – output file or directory.
setting_key – optional setting that referred to
path.
- spacr.run_journal.check_analysis_lock(settings: Dict[str, Any], *, app_key: str, lock: Dict[str, Any] | None = None, gates: Any = None) Dict[str, Any][source]¶
Check a run’s settings against its preregistered analysis lock.
Use the supplied analysis plan, or find the newest plan created by
lock_analysis()forapp_keyand the settings’src. Check the plan against its stored hash to detect changes to the file. Then compare each setting, each hashed file, and each recorded strategy for selecting measurement rows.Each difference is stamped with when it was first seen (kept beside the lock), and it is post-hoc only when it was first seen after a blinding key on a folder of the plan was opened: an edit made while still blind stays a deviation even when the run that uses it comes later.
- Parameters:
settings – the settings of the run being checked.
app_key – the pipeline running them.
lock – a lock record to check against instead of the newest one.
gates – the Gate Editor gating strategies in use, for gates the lock took as objects (see
lock_analysis()).
- Returns:
status–"unlocked"(no lock applies),"verified"(nothing changed),"deviation"(changed, every change first seen while still blind),"post_hoc"(a change first seen after a key on the plan’s folders was unblinded),"not_preregistered"(unchanged, but the lock was made after an unblinding) or"tampered"(the lock no longer matches its hash) – withlock_id,sha256,locked_utc,locked_by,pipelines,deviations(key,locked,now,first_seen_utc,post_hoc, anddetailfor gates),unblinded_utcand a one-sentencesummary.
- spacr.run_journal.current_run() Run | None[source]¶
Return the
Runcurrently open on this thread, orNone.Useful for pipeline internals (Cellpose model loaders, etc.) that want to record a model checkpoint hash without every caller needing to plumb the
Runobject through.State is thread-local because the batch runner and database browser can execute independent workers concurrently.
- spacr.run_journal.delete_runs(directories: Iterable[Any]) Tuple[int, List[str]][source]¶
Delete journalled run folders. Returns
(deleted, refused).THIS REMOVES FILES, so every guard below is load-bearing rather than defensive habit:
A path is deleted only if, once resolved, it is strictly INSIDE
runs_root(). A record’sdiris read from a manifest on disk, and a manifest is a file a user can edit;../../..in one must not reach anything. Resolving first is what makes the check real – comparing unresolved strings passes for a symlink that points anywhere.runs_root()ITSELF is refused. “Delete everything” is a loop over children, never a removal of the root, so a caller that computes an empty selection cannot take the journal with it.The run OPEN ON THIS THREAD is refused: deleting the folder a run is still writing into leaves it failing on its next write with an error that names nothing.
A path that is not a directory is refused rather than unlinked.
Refusals are RETURNED, not raised. Deleting fifty runs where one is live should delete forty-nine and say which one it kept – an exception at that point has already deleted an unknown number and tells the caller nothing about which.
- Parameters:
directories – run folder paths, as in a record’s
"dir".- Returns:
how many were removed, and a message per refusal.
- spacr.run_journal.diff_runs(run_a: Any, run_b: Any) Dict[str, Any][source]¶
Compare two journalled runs and report exactly what changed.
run_a/run_bmay each be a run folder (str/Path), a run-id or unambiguous id prefix (resolved underruns_root()), or aRunobject — seeresolve_run_dir().Results are bucketed by presence before value, because the settings schema drifts between releases and a flat diff of an old run against a new one is almost entirely schema noise:
{ "changed": [{"key": k, "a": av, "b": bv}, …], # THE signal "only_in_a": ["key", …], # schema drift / dropped options "only_in_b": ["key", …], # schema drift / new options "same": int, # count only, to keep this small "env": [{"key": k, "a": av, "b": bv}, …], # from manifests "meta": {"a": {...}, "b": {...}, "app_key_differs": bool}, }changedholds only keys present in both runs whose values actually differ, sorted by key — those are the knobs someone turned. Values are compared structurally, not byrepr(see_normalize_value()):[1, 2] == [1, 2],"[1, 2]"(as CSV round-trips it)== [1, 2], and"None" == None.envdiffsmanifest.json’senvsnapshot — spaCR version, git hash, python, platform, torch / cellpose / numpy versions — which is usually where an unexplained behaviour change actually lives.Comparing two runs of different
app_key(mask vs measure) is allowed — sometimes that is exactly the question — but flagged viameta["app_key_differs"]so the caller can warn that the two schemas were never meant to line up.A missing or corrupt
settings.json/manifest.jsonnever raises: whatever could be read is diffed and the problem is listed inmeta[side]["errors"].- Parameters:
run_a – baseline run reference.
run_b – comparison run reference.
- Returns:
the diff dict described above (JSON-serialisable as long as the settings themselves are).
- Raises:
FileNotFoundError – only when a run reference resolves to no run folder at all.
- spacr.run_journal.extract_seeds(settings: Dict[str, Any]) Dict[str, Any][source]¶
Capture declared seeds plus already-loaded RNG state fingerprints.
The state fingerprints do not import NumPy or Torch. When those libraries are already loaded, their state is captured; otherwise the manifest says so explicitly instead of changing application startup behavior.
- Parameters:
settings – settings dict; every nested key whose name contains
seed,random_stateorrandom_seedis collected.- Returns:
a dict with
declared(the collected seed settings),python_hash_seed(PYTHONHASHSEED, orNonewhen unset),python_random_state_sha256,numpy_random_state_sha256andtorch_initial_seed— the last twoNonewhen the library is not already imported. A probe that raises contributesnumpy_random_state_error/torch_seed_errorinstead of its value key.
- spacr.run_journal.format_run_diff(diff: Dict[str, Any], max_drift_names: int = 6) str[source]¶
Render
diff_runs()output as a readable console report.Ordering is deliberate: changed settings first (the signal), then the environment, then schema drift reduced to a one-line summary plus a handful of names. The drifted keys are never dumped in full — on a real cross-release pair that is ~200 lines of noise that buries the six settings the user actually changed.
- Parameters:
diff – the dict returned by
diff_runs().max_drift_names – how many drifted key names to name before collapsing the rest into
(+N more).
- Returns:
a multi-line report (no trailing newline).
- spacr.run_journal.hash_file(path: pathlib.Path, chunk_size: int = 1 << 20, *, full: bool = False) str | None[source]¶
Return a file’s SHA-256 — 16 hex chars, 64 if
full— orNone.- Parameters:
path – regular file to hash.
chunk_size – bytes read per iteration.
full – return all 64 hexadecimal characters. The default preserves the historic 16-character public result; reproducibility manifests use the full digest.
- spacr.run_journal.journal_totals() Dict[str, int][source]¶
Return aggregate counts across every stored run.
Powers the Home-screen insights dashboard:
total_runs(all manifests seen),mask_runs/measure_runs/classify_runs(per-app tallies), andmodels_recorded(distinct model hashes ever recorded, across runs of every app — not only mask runs). Returns zeros when no journal exists yet.Counting is incremental: the totals and the names of the folders already counted are persisted in
.journal_totals.jsonin the runs root, so each call parses only the manifests it has not seen. A folder that has since been deleted cannot be subtracted, so that case falls back to a full recount. A manifest that cannot be parsed is left out of the totals with a logged warning.
- spacr.run_journal.load_run_settings(run_dir: pathlib.Path) Dict[str, Any][source]¶
Read a run’s
settings.json(falling back to settings.csv).- Parameters:
run_dir – journal run directory containing the settings files.
- spacr.run_journal.lock_analysis(settings: Dict[str, Any], *, app_key: str, hypotheses: str = '', thresholds: Any = None, files: Iterable[Any] = (), note: str = '', gates: Any = None, models: Any = None, pipelines: Dict[str, Dict[str, Any]] | None = None) Dict[str, Any][source]¶
Freeze an analysis plan before its results are seen.
The settings, the stated hypotheses and thresholds, the SHA-256 of every model, gate or threshold file the settings name (plus
files), the gating strategies and the models are written with the time and the user to~/.spacr/analysis_locks/<lock id>.jsonand hashed. From then on every journalled run of a locked pipeline on itssrcis checked against the newest lock bycheck_analysis_lock(), and every model a run records is checked as it is recorded; the verdict goes into the run’smanifest.json, and a difference is listed in the manifest’s warnings, the report and the methods text.A lock made after a blinding key on a folder it covers was unblinded is marked as such, because it was not made blind.
- Parameters:
settings – the settings the analysis will run with.
app_key – the pipeline it will run in, such as
"classify".hypotheses – the hypotheses, in words.
thresholds – the decision thresholds and gates, in any JSON-compatible form.
files – further files the plan depends on.
note – anything else worth keeping with the plan.
gates – Strategies for selecting measurement rows. Accept a gate set, its dictionary representation, a saved strategy file, a list of these values, or a mapping from labels to these values. Also include strategy files referenced by settings. Check strategy files on every run. To check a strategy supplied as an object, pass it to
check_analysis_lock()using the same label.models –
{name: checkpoint path}for the models the analysis loads, under the names the pipeline records them by.pipelines – further pipelines of the same plan, as
{app_key: settings}; each is checked against its own settings on its ownsrc, and one unblinding on any of their folders counts for the whole plan.
- Returns:
the stored lock, including
lock_id,locked_utcandsha256.- Raises:
ValueError – when
pipelinesrepeatsapp_key, orgatesholds something that is not a gating strategy.FileNotFoundError – when a model in
modelsis not a file.
- spacr.run_journal.open_run(app_key: str, settings: Dict[str, Any]) Iterator[Run][source]¶
Open a fresh run journal folder around a pipeline invocation.
Example:
from spacr.run_journal import open_run with open_run("mask", settings) as run: run.record_model("cellpose_cyto", ckpt_path) preprocess_generate_masks(settings) run.set_status("success")
- Parameters:
app_key – pipeline id (
"mask","measure", …).settings – settings dict handed to the pipeline. Written to the run folder as both JSON and CSV.
- Yields:
the
Runobject.
- spacr.run_journal.recent_runs(limit: int = 10) List[Dict[str, Any]][source]¶
Return the
limitmost-recent runs newest-first.Ordered by the manifest’s
start_utctimestamp (parsed asdatetime.datetime), so runs opened in the same wall- clock second still sort correctly — folder names alone truncate to seconds and would produce ties. Only the newestmax(limit * 4, limit + 64)folders by name are opened at all (for non-negativelimit), so startup cost does not grow with the size of the journal.PASS
NoneFOR “EVERY RUN”, never a negative number. The truncation at the end isall_entries[:limit], solimit=-1reads the whole journal and then hands back all but the OLDEST entry – observed 2026-09-03 on an 11,027-run journal, which returned 11,026. A folder with nomanifest.jsonis skipped quietly; one whose manifest cannot be parsed is skipped with a logged warning.Each entry is a dict with keys
dir(Path),app_key(str),status(str),start_utc(ISO str),elapsed_s(float), and the rawmanifest(dict, best-effort).
- spacr.run_journal.resolve_run_dir(ref: Any) pathlib.Path[source]¶
Turn a run reference into a run-folder
Path.- Parameters:
ref – run object, directory path, run id, or unambiguous id prefix.
Accepts, in order of preference:
a
Runobject (uses itsdir),a path (
str/Path) to an existing run folder,a run-id — the folder basename, e.g.
"2026-07-23_214737_b66bae6b__mask"— resolved underruns_root(),an unambiguous prefix of a run-id (
"2026-07-23_2147"), handy from a shell.
- Raises:
FileNotFoundError – when nothing matches, or when a prefix matches more than one run.
- spacr.run_journal.runs_root() pathlib.Path[source]¶
Return
~/.spacr/runs; created on first access.
- spacr.run_journal.search_runs(query: str = '', *, app_key: str = '', status: str = '', limit: int | None = None) List[Dict[str, Any]][source]¶
Return searchable, dashboard-ready records for all journalled runs.
Search covers the run id, module, status, settings keys and values, input and output paths, warnings, failure traceback, and environment versions. Corrupt or interrupted run folders remain visible with an explicit
"corrupt"/"running"status and diagnostic warnings.- Parameters:
query – whitespace-separated case-insensitive terms; every term must occur somewhere in the record.
app_key – optional exact module filter.
status – optional exact status filter.
limit – maximum returned records after newest-first sorting.
- Returns:
JSON-friendly record dictionaries.
dirremains aPathfor convenient GUI use.
- spacr.run_journal.start_blinding(items: Iterable[Any], *, scope: str, src: Any = '', seed: int | None = None) Dict[str, Any][source]¶
Shuffle
itemsunder coded names and keep the key away from them.Blind scoring: the person scoring sees each item only by its code (
B0001,B0002and so on, numbered in the shuffled order) and in that order, so neither a name nor its neighbours say which plate, well or condition it came from. The key that maps codes back to items is written to~/.spacr/blinding/<key id>.json, outside the data folder, and every later event on it (unblinding, closing) is appended to<key id>.log.jsonlwith who did it and when.- Parameters:
items – the things being scored, such as crop paths or image files; repeats are kept once, in first-seen order.
scope – what is being scored, such as
"annotate"; stored with the key.src – the experiment folder the items belong to. Analysis locks on the same folder read this key’s unblinding record.
seed – the shuffle’s seed; a random one is drawn and stored when omitted, so the order can be rebuilt from the key.
- Returns:
key_id,order(the items, shuffled) andcodes({item: code}).
- spacr.run_journal.unblind(key_id: str, *, reason: str = '') Dict[str, str][source]¶
Open a blinding key, and record who opened it and when.
The record is appended to the key’s log before the key is returned, so the identities cannot be read through this call without leaving the record. An analysis lock on the same folder treats a difference found after this moment as post-hoc.
- Parameters:
key_id – the key’s id, as
start_blinding()returned it.reason – why it was opened; stored with the record.
- Returns:
{code: item}.- Raises:
FileNotFoundError – when there is no such key.
- spacr.run_journal.values_equal(a: Any, b: Any) bool[source]¶
True when
aandbmean the same thing.- Parameters:
a – first settings value to compare after normalisation.
b – second settings value to compare after normalisation.
Compares
_normalize_value()output structurally, falling back to areprcomparison for exotic values whose__eq__refuses to produce a bool (numpy-style elementwise comparison, etc.) — and to “not equal” if even that blows up. A settings comparison must never be the thing that raises.
Nested helpers¶
- _desktop_os_notify.quoted(text: str) str¶
textas an AppleScript string literal on one line.spacr/run_journal.py:1787
- _iter_files.excluded(candidate: Path) bool¶
Return whether
candidateresolves to or below an excluded root.A path that cannot be resolved is retained so one hostile entry does not abort or silently empty the rest of the file walk.
spacr/run_journal.py:652
- _send_notification.secret(name: str) str¶
The secret supplied to try, else the stored one.
spacr/run_journal.py:1831
- recent_runs._sort_key(e)¶
Sort a recent-run entry by parsed start time, then folder mtime.
spacr/run_journal.py:2023