Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions applications/dynacell/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ optional-dependencies.eval = [
# -- do not bump it casually. Tag, not a bare commit. v0.9.0a2 rewrites only the
# MicroSSIM RI-factor fit: chunked float64 reduction (peak 40.5 -> 12.9 GB on
# 576x640x960 float32) and a ValueError on empty input; measured equivalent.
"cubic @ git+https://github.com/alxndrkalinin/cubic.git@v0.9.0a2",
# v0.9.0a3 fixes single-image FSC/FRC splitting (binomial path, sampling-floor
# warning); production's two-image FSC/FRC is untouched -- measured exact.
"cubic @ git+https://github.com/alxndrkalinin/cubic.git@v0.9.0a3",
"dinov3 @ git+https://github.com/facebookresearch/dinov3",
"dynaclr",
"hydra-core>=1.2",
Expand Down Expand Up @@ -104,7 +106,7 @@ optional-dependencies.eval_gpu = [
# selection->waveorder, segmentation->cellpose (cellpose imported lazily).
optional-dependencies.preprocess = [
"cellpose>=4.2",
"cubic @ git+https://github.com/alxndrkalinin/cubic.git@v0.9.0a2",
"cubic @ git+https://github.com/alxndrkalinin/cubic.git@v0.9.0a3",
"iohub>=0.3.6",
"tqdm",
"waveorder",
Expand Down
6 changes: 4 additions & 2 deletions applications/dynacell/src/dynacell/evaluation/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ computing) for any GPU-accelerated numerical work — image preprocessing
before / after model inference, metric calculations, cropping/resizing,
percentile clips, Gaussian filters, etc. Cubic is a hard runtime
dependency of the eval extras (`applications/dynacell/pyproject.toml`
pins `cubic @ git+…@v0.9.0a2`). Do not gate cubic imports behind `try/except`
pins `cubic @ git+…@v0.9.0a3`). Do not gate cubic imports behind `try/except`
or fall back to scipy/skimage paths.

**The cubic version is part of the numeric contract, not just a dep.** Metric
Expand All @@ -25,8 +25,10 @@ values move across pins — measured 0.8.0a2 → 0.9.0a1: `Z_FSC_Resolution`
which treats an untagged entry as unconstrained.
- A bump measured to move no value beyond a stated tolerance lists the old
version under the new one in `CUBIC_VERSIONS_EQUIVALENT_TO` so its caches are still reused
(0.9.0a1 under 0.9.0a2). The map is keyed by the declared version, so the
(0.9.0a2 and 0.9.0a1 under 0.9.0a3). The map is keyed by the declared version, so the
next bump reuses nothing until someone measures an equivalence against it.
`check_cubic_pin` accepts those equivalent versions too, so evals queued on
the previous venv keep running across a measured-equivalent bump.

The GPU-resident Cellpose-SAM entry point is
`cubic.segmentation.segment_cpsam` (single host→device upload, masks
Expand Down
61 changes: 41 additions & 20 deletions applications/dynacell/src/dynacell/evaluation/provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,18 @@

This module makes that boundary detectable and non-repeatable:

* :func:`check_cubic_pin` fails a run whose environment disagrees with the
version the repo declares, instead of silently producing values from a
different numeric stack.
* :func:`check_cubic_pin` fails a run whose environment holds neither the
version the repo declares nor one measured equivalent to it, instead of
silently producing values from a different numeric stack.
* :func:`write_metrics_provenance` stamps the versions beside the metrics,
and :func:`metrics_provenance_matches` lets the final-metrics cache gate
refuse a cache built by a different ``cubic``.

A version bump measured to move no value beyond a stated tolerance lists the old
version under the new one in :data:`CUBIC_VERSIONS_EQUIVALENT_TO`, so its caches
stay reusable instead of forcing every leaf to recompute. 0.9.0a1 -> 0.9.0a2 is such a
bump: it rewrites only the MicroSSIM RI-factor fit.
stay reusable instead of forcing every leaf to recompute. 0.9.0a1 -> 0.9.0a2 and
0.9.0a2 -> 0.9.0a3 are such bumps: the first rewrites only the MicroSSIM RI-factor
fit, the second only single-image FSC/FRC paths that production does not run.

The same sidecar stamps the content hash of the CP reference the CP feature
metrics were scored in (:mod:`dynacell.evaluation.cp_reference`). CP KID/FID/
Expand All @@ -53,7 +54,7 @@
#: The ``cubic`` version this repo is built against. Must equal the pin in
#: ``applications/dynacell/pyproject.toml``; ``provenance_test.py`` asserts
#: they cannot drift apart.
REQUIRED_CUBIC_VERSION = "0.9.0a2"
REQUIRED_CUBIC_VERSION = "0.9.0a3"

#: For each declared ``cubic`` version, the earlier versions whose metric values it
#: reproduces within a stated tolerance, so their caches stay reusable. Keyed by the
Expand All @@ -71,7 +72,20 @@
#: the versions; the root finder lands on the same alpha. The other
#: ``pixel_metrics`` / ``mask_metrics`` columns do not use the RI-factor code and
#: were bit-identical on 1 FOV x 7 timepoints.
CUBIC_VERSIONS_EQUIVALENT_TO = {"0.9.0a2": frozenset({"0.9.0a1"})}
#:
#: 0.9.0a2 -> 0.9.0a3 changes single-image FSC/FRC only: it judges the split
#: sampling floor per direction, splits binomial counts before isotropic
#: resampling, and warns when a single-image checkerboard result is finer than
#: 2.83 x the pixel size. Production never enables the binomial path, and the
#: two-image FSC/FRC that ``compute_pixel_metrics`` runs is untouched. Tolerance:
#: exact. Measured 2026-09-30 on the A549-trained ``fnet3d_paper`` ER and nucleus
#: ``a549__denv`` leaves (FOV 0/0/fov0000, 7 + 10 timepoints, production eval.yaml
#: and spectral_pcc/base.yaml kwargs, A40): every pixel column, XY/Z FSC,
#: FRC_Resolution and Spectral_PCC differ by 0 abs / 0 rel. The binomial +
#: resample_isotropic control moves 1.8-6.1% under the same harness, so the
#: comparison can see a change. 0.9.0a1 stays listed: it reproduces 0.9.0a2
#: (entry above), which 0.9.0a3 reproduces exactly.
CUBIC_VERSIONS_EQUIVALENT_TO = {"0.9.0a3": frozenset({"0.9.0a2", "0.9.0a1"})}

#: Sidecar written next to ``pixel_metrics.csv`` by :func:`write_metrics_provenance`.
PROVENANCE_FILENAME = "metrics_provenance.json"
Expand All @@ -95,19 +109,28 @@ def installed_versions() -> dict[str, str]:
return {name: version(name) for name in _RECORDED_PACKAGES}


def _accepted_cubic_versions() -> frozenset[str]:
"""Return the declared ``cubic`` and the versions measured to reproduce its values."""
return CUBIC_VERSIONS_EQUIVALENT_TO.get(REQUIRED_CUBIC_VERSION, frozenset()) | {REQUIRED_CUBIC_VERSION}


def check_cubic_pin() -> None:
"""Raise when the installed ``cubic`` is not the declared one.
"""Raise when the installed ``cubic`` is neither the declared one nor measured-equivalent to it.

An equivalent version is accepted because its values are, by measurement, the
declared pin's values: that is what lets eval jobs queued on the previous
venv keep running across a measured-equivalent bump.

Raises
------
RuntimeError
If the installed ``cubic`` version differs from
:data:`REQUIRED_CUBIC_VERSION`. Fails closed on purpose: a mismatched
stack writes plausible values under the wrong numeric contract, which
is exactly the failure this module exists to prevent.
If the installed ``cubic`` version is not :data:`REQUIRED_CUBIC_VERSION`
or listed under it in :data:`CUBIC_VERSIONS_EQUIVALENT_TO`. Fails closed
on purpose: a mismatched stack writes plausible values under the wrong
numeric contract, which is exactly the failure this module exists to prevent.
"""
installed = version("cubic")
if installed != REQUIRED_CUBIC_VERSION:
if installed not in _accepted_cubic_versions():
raise RuntimeError(
f"cubic {installed!r} is installed but this repo declares "
f"{REQUIRED_CUBIC_VERSION!r}. Metric values are not comparable across "
Expand Down Expand Up @@ -181,20 +204,18 @@ def metrics_provenance_matches(save_dir: Path, *, cp_space_sha256: str | None) -
Returns
-------
bool
True when the recorded ``cubic`` version equals the installed one (or is
in :data:`CUBIC_VERSIONS_EQUIVALENT_TO` for the declared pin while the installed one is
the declared pin) and, if ``cp_space_sha256`` is given, the recorded binding
equals it.
True when the recorded ``cubic`` version equals the installed one (or both
are the declared pin or listed under it in :data:`CUBIC_VERSIONS_EQUIVALENT_TO`)
and, if ``cp_space_sha256`` is given, the recorded binding equals it.
"""
path = save_dir / PROVENANCE_FILENAME
if not path.is_file():
return False
payload = json.loads(path.read_text())
recorded = payload.get("versions", {}).get("cubic")
installed = version("cubic")
equivalents = CUBIC_VERSIONS_EQUIVALENT_TO.get(REQUIRED_CUBIC_VERSION, frozenset())
reproduced = installed == REQUIRED_CUBIC_VERSION and recorded in equivalents
if recorded != installed and not reproduced:
accepted = _accepted_cubic_versions()
if recorded != installed and not (recorded in accepted and installed in accepted):
return False
if cp_space_sha256 is None:
return True
Expand Down
19 changes: 17 additions & 2 deletions applications/dynacell/src/dynacell/evaluation/provenance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ def test_equivalence_only_applies_under_the_declared_pin(tmp_path, monkeypatch):

def test_unmeasured_pin_has_no_equivalents(tmp_path, monkeypatch):
"""A newly declared pin with no measured entry reuses no older cache."""
monkeypatch.setattr("dynacell.evaluation.provenance.REQUIRED_CUBIC_VERSION", "0.9.0a3")
monkeypatch.setattr("dynacell.evaluation.provenance.version", lambda _name: "0.9.0a3")
monkeypatch.setattr("dynacell.evaluation.provenance.REQUIRED_CUBIC_VERSION", "0.9.0a99")
monkeypatch.setattr("dynacell.evaluation.provenance.version", lambda _name: "0.9.0a99")
_stamp_cubic(tmp_path, "0.9.0a1", cp_space_sha256=None)
assert not metrics_provenance_matches(tmp_path, cp_space_sha256=None)

Expand All @@ -167,6 +167,21 @@ def test_check_cubic_pin_accepts_the_declared_version():
check_cubic_pin()


def test_check_cubic_pin_accepts_an_equivalent_version(monkeypatch):
"""A venv on a measured-equivalent version keeps working across the bump."""
equivalent = next(iter(CUBIC_VERSIONS_EQUIVALENT_TO[REQUIRED_CUBIC_VERSION]))
monkeypatch.setattr("dynacell.evaluation.provenance.version", lambda _name: equivalent)
check_cubic_pin()


def test_equivalent_environment_reuses_the_declared_pins_cache(tmp_path, monkeypatch):
"""Equivalence is symmetric: an equivalent venv reuses a cache the declared pin wrote."""
_stamp_cubic(tmp_path, REQUIRED_CUBIC_VERSION, cp_space_sha256=None)
equivalent = next(iter(CUBIC_VERSIONS_EQUIVALENT_TO[REQUIRED_CUBIC_VERSION]))
monkeypatch.setattr("dynacell.evaluation.provenance.version", lambda _name: equivalent)
assert metrics_provenance_matches(tmp_path, cp_space_sha256=None)


def test_check_cubic_pin_rejects_a_mismatch(monkeypatch):
monkeypatch.setattr("dynacell.evaluation.provenance.version", lambda _name: "0.8.0a2")
with pytest.raises(RuntimeError, match="not comparable across"):
Expand Down
9 changes: 5 additions & 4 deletions applications/dynacell/tools/run_eval_direct.slurm
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ ml uv
# the 0.9.0a1 venv existed, so jobs that did not export DYNACELL_EVAL_VENV kept
# silently producing 0.8.0a2 FSC/FRC/Spectral_PCC values — 62 of the 121
# surviving eval logs. ``check_cubic_pin`` now fails such a run loudly, but the
# default still has to be right. ``cpdino-eval-cubic090a2`` is the
# ``cpdino-eval-cubic090a1`` freeze with only cubic changed; 0.9.0a1 caches stay
# reusable under it (``provenance.CUBIC_VERSIONS_EQUIVALENT_TO``).
export UV_PROJECT_ENVIRONMENT="${DYNACELL_EVAL_VENV:-/hpc/mydata/alex.kalinin/VisCy-venvs/cpdino-eval-cubic090a2}"
# default still has to be right. ``cpdino-eval-cubic090a3`` is the
# ``cpdino-eval-cubic090a2`` freeze with only cubic changed; 0.9.0a2 and 0.9.0a1
# are measured equivalent (``provenance.CUBIC_VERSIONS_EQUIVALENT_TO``), so their
# caches stay reusable and jobs already queued on the 0.9.0a2 venv still run.
export UV_PROJECT_ENVIRONMENT="${DYNACELL_EVAL_VENV:-/hpc/mydata/alex.kalinin/VisCy-venvs/cpdino-eval-cubic090a3}"
export HF_HUB_CACHE="${HF_HUB_CACHE:-/hpc/projects/comp.micro/virtual_staining/models/dynacell/evaluation/hf_cache}"
export PYTHONUNBUFFERED=1

Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading