Skip to content

Treat an image stack's z axis as a slice index, not a spatial axis - #6

Open
krokicki wants to merge 14 commits into
mainfrom
cached-info-authority
Open

Treat an image stack's z axis as a slice index, not a spatial axis#6
krokicki wants to merge 14 commits into
mainfrom
cached-info-authority

Conversation

@krokicki

@krokicki krokicki commented Aug 15, 2026

Copy link
Copy Markdown
Member

Rolls up the image-stack-z-axis branch. A tilt series' z axis is a tilt index, not a spatial axis, and the server was treating it as spatial. The cached-info work in here exists because fixing that needs a cache that can invalidate itself correctly.

The bug

cella_z is meaningless on an image stack, but writers stamp it anyway — Relion copies the x/y pixel size onto the tilt axis, so 55 tilts came out 8.3 nm "thick" against a 621 nm-wide image. A 75× squash that left the tilt axis unnavigable in Neuroglancer. Downsampling made it worse: binning z averaged tilts taken at different angles into one plane. And the (mx,my,mz) != (nx,ny,nz) guard rejected 110 of the corpus's 2871 stacks with HTTP 422, because mz=1 regardless of nz is the convention on those files rather than a defect.

For a file classified as an image stack:

  • z resolution is advertised as 1 nm per slice, ignoring cella_z (precomputed has no way to say "this axis is an index", and rejects a zero resolution outright). info carries a non-spec "is_image_stack": true.
  • plan_scales never bins z — scale keys go 2_2_1, 4_4_1, …
  • the grid-size guard doesn't apply

Why globbing, not the aspect-ratio heuristic

Something has to decide, per file, which kind it is. The first attempt inferred it from shape (nz / max(nx, ny) below a threshold → stack). That cannot be made correct, and the corpus says so numerically: measured over all 3648 files, cross-checked against the directory convention,

nz / max(nx, ny)
true 2D (stacks) 0.0001 – 0.2200
true 3D (volumes) 0.1276 – 1.4120

The ranges overlap. No threshold separates the classes, so retuning the constant only moves which files are wrong — the shipped value was knowingly wrong on 2 files, both small-format synthetic tilt series.

The header can't answer it either. ispg is 0 on every file in the corpus, tomograms included, and writers agree on nothing else: Relion stamps mz=nz with cella_z = nz * pixel_x, IMOD stamps mz=1 with a dummy cella_z, and only some stacks carry an extended header. There is no field to read.

What does know is the operator — the information lives in the directory layout and the naming convention, which is knowledge about the experiment, not about the bytes. So the classification stops being derived and becomes an input: --stack-glob / --volume-glob on mrc-pyramid, MRCNG_STACK_GLOBS / MRCNG_VOLUME_GLOBS on the server.

mrc-pyramid build ... \
    --stack-glob '*/TiltSeries/*' --stack-glob '*/Gains/*' \
    --volume-glob '*/Tomograms/*' --volume-glob '*_ctf.mrc'

Volume globs win over stack globs, because real trees mix both kinds in one directory — this corpus has external/s200.mrc (a 55-tilt stack) beside external/s200_ctf.mrc (a 512×512×55 volume). An include-only list can't separate those without per-filename patterns.

This trades an inference that is provably unfixable for configuration that has to be kept in sync, and the fingerprint absorbs that cost: the build records the classification it used, and validate() rejects a mismatch as INCOMPATIBLE. Two things follow —

  • changing the globs invalidates exactly the entries it would reclassify, so a plain mrc-pyramid build picks them up; no --force sweep
  • a server whose globs disagree with the build's degrades to single-resolution instead of the two silently disagreeing about what z means

is_image_stack also stops being a header property and becomes an input threaded into parse_header, so build and serve can't derive it twice and diverge.

Verified on the corpus: the glob set above classifies all 3648 files correctly, including the 250×150×55 synthetic stack the heuristic misread and the _ctf volumes sitting beside their stacks.

Supporting work: the cached info becomes authoritative

The z fix changes what a build produces, which only helps if existing caches stop being served as valid — the earlier zero-cella_z fix shipped and then served stale "resolution": [.., .., 0.0] for weeks. So:

  • /info for a cached file now returns the verbatim bytes the build wrote instead of recomputing them on the request path, so info can never disagree with the chunk files next to it
  • the fingerprint gains DERIVATION_VERSION (bumped by hand when a change alters info, the scale plan, or the chunk bytes) and the per-scale sizes, so stale entries read as outdated and cached chunk extents are validated against the scale plan the build actually used
  • a corrupt info or a non-iterable scale-sizes value degrades to no-cache rather than a 500
  • CLAUDE.md and the README state when to bump DERIVATION_VERSION, and how it differs from SCHEMA_VERSION

Two follow-up fixes to the glob plumbing are also here: MRCNG_STACK_GLOBS was tuple[str, ...], and pydantic-settings JSON-parses complex-typed fields straight from the environment, so the documented comma-separated value raised SettingsError and the server wouldn't start — now a plain str split at the use site, matching cors_origins. And the server matched globs against the absolute path while the builder matched the relpath; */TiltSeries/* works either way (which is why tests passed) but an anchored Experimental/* doesn't.

Operational note

SCHEMA_VERSION 2 → 3 for the new fingerprint key, which subsumes a DERIVATION_VERSION bump. Existing caches must be rebuilt: a stack's z-binned scale keys are no longer advertised, so affected files serve single-resolution until then. No wrong bytes are served in the meantime.

Test plan

pixi run -e default pytest -q

@StephanPreibisch @allison-truhlar

krokicki and others added 13 commits August 13, 2026 17:59
A tilt series' z axis indexes tilts and has no spatial extent, but Relion
stamps cella_z = nz * pixel_x on these files, so deriving a z voxel size
from the header gave 55 tilts an 8.3nm extent against a 621nm-wide image
-- a 75x squash that left the tilt axis unnavigable in Neuroglancer.

MRC has no reliable flag for this. ispg is 0 on every file in the Janelia
cryoET corpus, tomograms included, and writers agree on nothing else
either: Relion sets mz=nz, IMOD sets mz=1 with a dummy cella_z, and only
some stacks carry an extended header. Infer it from shape instead.

For an image stack:

- z resolution is advertised as 1nm per slice, ignoring cella_z, since
  precomputed has no way to say "this axis is an index" and rejects a
  zero resolution outright
- plan_scales never bins z, so no level averages tilts taken at
  different angles into one plane
- the (mx,my,mz) != (nx,ny,nz) guard no longer applies, because mz=1
  regardless of nz is the convention there rather than a defect -- it
  was rejecting 110 of the corpus's 2871 stacks outright with HTTP 422

Build and serve derive the classification independently from the same
header fields, so they agree by construction; nothing is read back from
the cached info.

The heuristic is known wrong on 2 of 3648 corpus files and cannot be
fixed by retuning the constant: cross-checked against the directory
convention, true 2D files span ratio 0.0001-0.2200 and true 3D files
span 0.1276-1.4120, so the two classes overlap. Both misses are
small-format synthetic tilt series. Recorded in the _is_image_stack
comment and the README, with a test pinning the limitation so the next
person sees the tradeoff instead of believing they fixed it.

Caches built before this need a --force rebuild: a stack's z-binned keys
are no longer advertised, so those files fall back to single-resolution
until rebuilt. No wrong bytes are served in the meantime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Schema v2. DERIVATION_VERSION is bumped by hand whenever a change alters what a
build produces, which invalidates every cache entry so it rebuilds against the
new behaviour. New Validity.OUTDATED distinguishes 'the code moved' from 'the
source changed' in mrc-pyramid status.

scales becomes a key -> [sx,sy,sz] mapping so the server can validate a chunk
extent without recomputing the scale plan.
Reverses 46e8a88 now that validate() invalidates on derivation change. The
served info and the chunks beside it come from the same build, so info can no
longer advertise a level the cache lacks -- which recomputing the scale plan
and intersecting it with fp[scales] risked silently.

ETag now covers derivation_version: after a derivation change and rebuild the
source is byte-identical, so a source-hash-only ETag would let a 304 pin stale
info.
…h serving from recomputing

The prior version compared the response to cache_dir/info unmutated, which
json.dumps(build_info(...)) happened to reproduce byte-for-byte in this
fixture -- so the test passed against the old recompute-from-header code too
and carried none of the deleted regression test's protection. Add a sentinel
key build_info could never produce, and assert the response matches those
mutated bytes exactly.
Removes the last place the server re-derived the scale plan, so it can no
longer disagree with the build that wrote the chunks. app.py no longer needs
is_image_stack or the fingerprint's min_axis_size/max_levels.
The cache now serves info and chunks verbatim, and nothing detects that the code
which produced them changed -- so the bump rule is the safety mechanism. It lives
in CLAUDE.md because the person who needs it is editing mrcheader.py, not
fingerprint.py. CLAUDE.md also carries the other traps an editing agent needs
(dtype vs served_dtype, edge-chunk clipping, pread-only, the STACK_ASPECT_RATIO
heuristic, notes/ vs docs/, the test command, and a pointer to the reader.py doc).
Widen _serve_info's cache-hit read to also validate the info bytes parse as
JSON, and _serve_chunk's ScaleLevel construction to catch TypeError, so both
join every other corrupt-fingerprint path in falling back gracefully instead
of serving garbage or 500ing. Also initialize etag next to body so its
binding across the two-stage cache_hit flow is locally obvious to linters.
Each constant explained itself but nothing said how to choose between them, and
bumping the wrong one does not invalidate the cache at all.

SCHEMA_VERSION covers the shape of fingerprint.json (which keys exist, what types
they hold) and yields INCOMPATIBLE; DERIVATION_VERSION covers the content a build
produced (values in info, bytes in chunks) and yields OUTDATED. The deciding
question is whether an existing fingerprint would still parse and compare
correctly.

Also records two asymmetries that are easy to get backwards: a schema bump is
checked first and already invalidates everything, so it subsumes a derivation
bump, while the converse does nothing for a fingerprint this code cannot read;
and generator_version is recorded but never compared, so a release bump must not
invalidate a corpus on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both places listed the five modules by name without saying what qualified them,
so the list could not classify a module added later -- and a new module that
changes build output but is not on the list is exactly the silent-staleness case
the constant exists to prevent.

The rule: can this code alter the values in info or the bytes in a chunk file?
The list is now presented as today's consequence of that question rather than as
the definition, with one clause per module on what it decides.

Also records why the excluded modules are excluded, since they fail in different
ways -- notably paths.py, where changing dataset_id orphans cache entries rather
than staling them, which is prune's job and not a bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shape heuristic could not be right. Measured over the 3648-file corpus, true
2D files span nz/max(nx,ny) 0.0001-0.2200 and true 3D files 0.1276-1.4120, so the
classes overlap and no threshold separates them; it was knowingly wrong on 2
files. Replace it with configuration: --stack-glob / --volume-glob on
mrc-pyramid, MRCNG_STACK_GLOBS / MRCNG_VOLUME_GLOBS for the server.

is_image_stack stops being a header property and becomes an input threaded to
parse_header, so nothing derives it twice. Volume globs win over stack globs
because real trees mix both in one directory -- this corpus has external/s200.mrc
(55-tilt stack) beside external/s200_ctf.mrc (512x512x55 volume), and an
include-only list cannot separate those without per-filename patterns.

The fingerprint records the classification the build used, and validate() rejects
a mismatch as INCOMPATIBLE. Two things follow: changing globs invalidates exactly
the entries it reclassifies, so a plain build picks them up; and a server whose
globs disagree with the build's degrades to single-resolution instead of the two
silently disagreeing about what z means. SCHEMA_VERSION 2 -> 3 for the new key,
which subsumes a DERIVATION_VERSION bump.

Verified on the corpus: the glob set in the README classifies all 3648 files
correctly, including the 250x150x55 synthetic stack the heuristic misread and the
ctf volumes sitting beside their stacks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
stack_globs/volume_globs were tuple[str, ...], and pydantic-settings JSON-parses
any complex-typed field straight out of the environment, so the documented
MRCNG_STACK_GLOBS='*/TiltSeries/*,*/Gains/*' raised SettingsError before a
validator could see it -- the server would not start at all.

Take the shape cors_origins already uses for the same reason: a plain str split
at the use site by parse_globs(). Adds the two variables to the README settings
table, which had documented them only in prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mrc-pyramid only ever has the relpath, so it matches that; the server was
matching str(path), the absolute path. `*/TiltSeries/*` works either way, which
is why the tests passed, but an anchored pattern like `Experimental/*` matches
the relpath and not the absolute path. The two sides then disagree, the
fingerprint reads INCOMPATIBLE, and the dataset silently drops to
single-resolution -- safe, but invisible.

FdCache takes source_root and derives the relpath, falling back to the absolute
path if a file is somehow outside the root rather than failing a read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@krokicki krokicki changed the title Serve the cached info verbatim, and classify image stacks by operator globs Treat an image stack's z axis as a slice index, not a spatial axis Aug 16, 2026
@krokicki

Copy link
Copy Markdown
Member Author

@allison-truhlar I'm not sure how realistic the current globbing strategy is, we should discuss further in our next dev meeting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant