Skip to content

Bind masks to their fingerprints for the whole run and the relationship scan - #180

Merged
guijacquemet merged 3 commits into
mainfrom
claude/mask-fingerprint-binding
Nov 6, 2026
Merged

guijacquemet merged 3 commits into
mainfrom
claude/mask-fingerprint-binding

Conversation

@guijacquemet

@guijacquemet guijacquemet commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #160, fixes #165

Summary

  • Masks are not fingerprint-bound during the run #160 (run), masks/checks.py, masks/files.py, run/preflight.py, mask_record.py, run/pipeline.py, new mask_binding.py: every mask's header is read between two fingerprints that must agree (find_masks(bind=True), MaskFile.bound). If they differ, the run stops with the new error mask_changed_during_check. The manifest now records that header-bound fingerprint; before, it was taken when the manifest was written. Every mask must still have the fingerprint right before the commit. Otherwise the run stops with mask_changed_during_run, which names the mask and what changed, and nothing is published. A verify_content run also hashes every mask after the header checks and again as the last step before the commit.
  • Refuse mask relationship results if masks change while they are being scanned #165 (scan), mask_scan.py, run/mask_step.py, cli_masks.py: prepare_mask_step binds the mask headers. scan_masks fingerprints each image's masks before their first plane is read and after their last. With verify_content it also takes their SHA-256 (set through prepare_mask_step(verify_content=...) or masks --verify-content/--no-verify-content; by default the recorded run's setting). A changed or missing mask raises MaskChangedError, a ValueError subclass (code mask_changed_during_scan). No report is written and an earlier mask_relationships.json stays as it was. The report is still written whole by one rename after every image is scanned, so nothing half-written can look complete.

Decisions

  • Identity is the metadata fingerprint. Content hashing stays opt-in (Content hashing of sources is off by default #159, unchanged).
  • No schema change: manifest versions 2 and 3 and their mask entries keep the same keys. For unchanged masks the recorded values are the same, and an unchanged rerun leaves the manifest identical byte for byte (tested). The masks' SHA-256 is compared in memory and not recorded. The report format is unchanged.

Notes for review

Tests

  • New: tests/test_mask_run_binding.py (7) and tests/test_mask_scan_binding.py (10), covering TIFF masks and OME-Zarr label images. Against main's source, 15 of them fail. The other 2 are invariants that pass on main as well: an unchanged rerun keeps the outputs byte for byte, and the default run's stated blind spot.
  • python tools/check.py (Linux, Python 3, .[dev,notebook,zarr]): ruff check and format clean; 2311 passed, 4 skipped (3 need a case-insensitive file system, 1 needs permissions that root ignores); code rules clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW

Each mask's header is now read between two fingerprints that must agree
(find_masks(bind=True), MaskFile.bound; else mask_changed_during_check),
the manifest records that fingerprint instead of one taken when it is
written, and every mask must still have it right before the commit
(mask_changed_during_run, naming the mask and what changed; nothing is
published). A verify_content run also hashes every mask after the header
checks and again as the last step before the commit. No schema change:
unchanged reruns keep the manifest byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
The mask step now fingerprints each mask with its header
(prepare_mask_step binds the headers) and each image's masks before
their first plane is read and after their last. A mask that no longer
has the fingerprint taken with its header, went missing, or changed
during its scan raises MaskChangedError (a ValueError; the error
mask_changed_during_scan, which the masks command prints): no report is
written and an earlier one is left as it was. With verify_content
(prepare_mask_step(verify_content=...), masks --verify-content; by
default the recorded run's setting) the content SHA-256 is compared too.
The report format is unchanged and unchanged masks give the same report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
@guijacquemet
guijacquemet force-pushed the claude/mask-fingerprint-binding branch from c07d452 to 6a0d62b Compare November 6, 2026 16:36
… it (#165)

describe_masks raises MaskChangedError (a ValueError) when a mask changes while
it is read. The notebook's Describe the masks caught it as an unusable mask and
told the user to correct or replace the mask. It now shows the error card of
mask_changed_during_scan, which names the mask and says to wait until nothing
writes to it, as the masks command does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW

Copy link
Copy Markdown
Contributor Author

Independent review: approve after one fix (now at 65b78619009c4b9f5cf80788cff7cb029c902fba)

I reviewed head 6a0d62b against AGENTS.md, contract §9.4/§15, and the owner decisions: identity is the metadata fingerprint, and --verify-content is opt-in.

Manifest schema: the PR body seemed to contradict itself, but both statements are true

The schema did not change. MaskInventory.to_manifest still emits the same keys, and mask_manifest still adds size/mtime_ns/(inventory_sha256) plus axes/roles/axes_from. Only the value's source changed: mask.bound or Fingerprint.of(...). That is the fingerprint taken around the header read, where before it was taken at manifest-write time. Between the check and the commit, the fingerprint lives in memory on MaskFile.bound, a new dataclass field that is not serialised. The mask content SHA-256 lives in _Run.mask_content, also not recorded. So "the manifest records that fingerprint" means the existing fields now hold the bound value. For unchanged masks the value is identical, so reruns are byte-identical (tested) and old manifests load as before. No version bump or migration is needed.

Claims verified

  • Masks are not fingerprint-bound during the run #160: _header_issues(bind=True) fingerprints before and after _check_header. If they differ, the result is mask_changed_during_check. _publish re-checks changed_masks inside staged_generation. With verify_content, masks are hashed after the header checks and again in the before_commit hook, right before the manifest rename. A mismatch gives mask_changed_during_run, which names the masks and what changed, and nothing is published.
  • Refuse mask relationship results if masks change while they are being scanned #165: scan_fingerprints/check_scanned bracket each image's plane reads, and the pre-scan check is compared against bound. MaskChangedError propagates before write_mask_report, which is atomic. So no report is written, an earlier one stays intact, and no temp file is left (tested). masks --verify-content/--no-verify-content defaults to the run's recorded setting through resolve_inputs (tested).
  • Tests are real: against main's src, 5 of 7 run-binding tests fail and the scan-binding tests cannot import. The 2 that pass on main are the stated invariants.

Defect fixed (65b7861, failing-first test)

The notebook's Describe the masks caught MaskChangedError in its generic ValueError handler. It then showed "The masks cannot be used … Correct or replace the mask named". For a mask that a segmentation is still writing, that advice is wrong. The notebook now catches MaskChangedError first and shows the mask_changed_during_scan error card, which names the mask and says to wait until nothing writes to it. This matches the masks command and the user guide. Test: tests/test_notebook_mask_step.py::test_a_mask_changed_while_it_is_described_says_to_wait_not_to_correct_it. The CHANGELOG is updated.

Notes (not blocking)

  • TOCTOU: the default-run mask fingerprint check sits in the staged body, before the staged report is written and before the rename. This matches how images are handled ([Sad Catapella B5][Scientific integrity] Source files may change between fingerprint capture and histogram publication #90). Only the verify_content hash runs in before_commit. A change in that small window, or after the commit, is still caught downstream: the manifest records the bound fingerprint, and RunInputs.open checks mask fingerprints. Optional tightening: run both fingerprint re-checks in before_commit.
  • Changed and changed back (ABA) with the same size and mtime is accepted, as the metadata-fingerprint decision says. With verify_content, a content ABA within one scan or run window is also not seen.
  • mtime resolution: st_mtime_ns is effectively ns on ext4/APFS and 100 ns on NTFS. It is 1 s on HFS+, 2 s on FAT32, and 10 ms on exFAT. Network/FUSE mounts (NFS attribute cache, Google Drive in Colab) can serve stale size/mtime. A same-size rewrite within that resolution or cache time falls into the documented blind spot, so --verify-content is the remedy there. Possibly worth one user-guide line.
  • check does not bind headers, so mask_changed_during_check comes only from run, masks and conversion. That is fine.

Gate

python tools/check.py (Linux, Py 3.12, .[dev,notebook,zarr]): ruff check and format clean, 2312 passed, 4 skipped (3 need a case-insensitive file system, 1 needs file-mode permissions that root ignores; none of the skips are in the new tests, and the zarr label-image tests ran), code rules clean. CI on 65b7861: lint plus test on ubuntu/windows/macOS × 3.11/3.13, all 7 succeeded.

Verdict: correct, and data integrity holds. Safe to merge at 65b7861 (not merged by me).


Generated by Claude Code

@guijacquemet
guijacquemet marked this pull request as ready for review November 6, 2026 17:27
@guijacquemet
guijacquemet merged commit 34cbf1c into main Nov 6, 2026
7 checks passed
@guijacquemet
guijacquemet deleted the claude/mask-fingerprint-binding branch November 6, 2026 17:27
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.

Refuse mask relationship results if masks change while they are being scanned Masks are not fingerprint-bound during the run

2 participants