Skip to content

PP-11c: conversion integrity (copy SHA-256, metadata epochs, exact CLI sizes) - #126

Merged
guijacquemet merged 4 commits into
mainfrom
claude/pp-11c-conversion-integrity
Nov 2, 2026
Merged

guijacquemet merged 4 commits into
mainfrom
claude/pp-11c-conversion-integrity

Conversation

@guijacquemet

@guijacquemet guijacquemet commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the five closures an independent review of 8db1f4a found premature (issues reopened by the owner). Binding spec: design v0.4 §1, §5, §6, §9; contract §17; plan question 59.

Fixes #80, #87, #88, #92, #96

(#96: the correctness part, mixed metadata epochs, is fixed. See "#96: header reads that remain" below for the performance part. If the owner wants that part kept open, change Fixes #96 to Refs #96 before merging.)

Summary (by module)

  • Move only, first commit (f8cb39a): conversion.py (587/600 lines) split into conversion_plan (plan_conversion, PlannedCopy; orchestration), conversion_reuse (compare with an earlier copy tree; io) and conversion_write (.partial writing; io). Helpers used across modules lose their leading underscore. Tests now patch write_named_copy/os.replace in conversion_write.

  • [Sad Catapella B6][Performance] TIFF-to-Zarr conversion reopens headers to read physical calibration already inspected #96, one metadata epoch: preflight(bind_headers=True) takes each image's fingerprint before and after reading its header (Preflight.header_fingerprints). plan_conversion refuses any image whose two fingerprints differ or that went missing (source_changed_during_check, an error naming the files, nothing written). That bound fingerprint is recorded as the source's and checked before and after the copy is written, as before. The copy's pixel type now comes from the same header: write_named_copy(..., dtype=) no longer calls plane_layout to reopen the header. A plane whose type differs from the header's is refused (CopyWriteError, so conversion_failed). It is never cast.

  • [Sad Catapella B5][Scientific integrity] Reused TIFF-converted Zarr copies are not validated against their stored pixels #87, the copies' SHA-256 (owner decision): new content_hash.content_sha256. A file is hashed as is. An OME-Zarr folder is hashed from its files' relative paths and SHA-256s in path order, with labels left out (as for fingerprints). Each copy is hashed in one pass after it is written and moved into place, and the hash is recorded as copy_sha256. Three places check it:

    • reuse (conversion_reuse): needs the plan, the fingerprint and the SHA-256 to match;
    • review_conversion (check/run on a copy tree): copy_changed_after_conversion also covers a same-size change with the time put back;
    • RunInputs.open (new conversion_check): checks the record SHA-256, the record version, and the fingerprint and SHA-256 of each copy the run recorded.

    A copy that cannot be read counts as changed. Sources keep size+mtime (design §1).

  • [Sad Catapella B5][Correctness] Channel-label copy freshness check accepts missing or truncated OMERO metadata #88: CopyEntry.channel_names and significant_bits hold what the copy contains: as written for converted images, from the header for copied ones. same_plan() compares both.

  • [Sad Catapella B6][Scientific calibration] Reused OME-Zarr copies are not checked for numeric pixel-scale correctness #92: same_plan() compares unknown_sizes in full (roles and reasons). The copy hash also covers the calibration written into the copy.

  • [Sad Catapella B3][Validation] ConversionSettings accepts noninteger numeric inputs that fail downstream #80: cli_run.parse_size is exact (Fraction, ASCII digits only). The result must be a whole number of bytes above 0: 1.5GiB gives 1610612736, while 1.5, 0.5B, 0.5 and 1.0000000001KiB are refused. Previously 9007199254740993 lost precision through float. --memory-limit is only on run and convert (check and masks don't take it). argparse calls parse_size while reading the command line, so a bad size exits with 2 before any preflight or I/O. A test proves this for both commands.

Decisions

  • Conversion record format change: record_version goes from "1" to "2". The new per-entry fields are copy_sha256, channel_names and significant_bits, and an entry without copy/copy_sha256 is invalid. A version 1 record is refused everywhere it is read (convert, review_conversion → conversion_record_unreadable, RunInputs.open), and the message says to convert the originals again into a new folder. It is never silently accepted. This is documented in contract §17 and the user guide (copies made by v0.4.0rc3 or earlier).
  • What RunInputs costs: it hashes once, at open, the copies of every image in manifest.files, which is one full read of those copies. Excluded images' copies are not read, since RunInputs never reads them. I rejected hashing on every read because it would read a copy whole once per plane. Reads after open keep the per-read fingerprint check. Measured on this machine: 256 MiB hashed in 0.38 s with a warm cache, so a cold read is bounded by disk speed. check/run on a copy tree pay the same one extra read (in review_conversion). This is documented in contract §9.1 and §17 and the user guide.
  • New error code source_changed_during_check. I did not reuse conversion_source_changed, which is the run's warning about sources changed after the conversion.
  • Recorded in plan §12 question 59 (16) (copy SHA-256), (17) (epoch binding) and (18) (exact sizes), and in plan §10 (PP-11c).

#96: header reads that remain

  • Removed: write_named_copy's plane_layout(source, …), which reopened the header only to get the pixel type.
  • Still read: the bound inspection in preflight (once per image). The calibration and layout come from it, through copy_layout, which is pure and does not read the file.
  • Still read: check_planes at plan time, once per image to convert. This is the run's own memory and block check in planes.py. Its block layout needs the TIFF page and strip structure, which TiffMetadata does not hold, and planes.py is far over its size baseline. If the file changed, the write-time fingerprint check refuses it.
  • Still read: the reader (iter_planes) opens the file and parses its header to read the pixels. This is unavoidable, and it happens between the two checks against the bound fingerprint.
  • Still read: the CLI convert runs one preflight of its own (for --axes checking) before convert_experiment's bound preflight.
  • No benchmark of open counts or elapsed time at 100/1k/10k files has been run, so this PR makes no performance claim.

§9 figures (lines, origin/main → this branch)

conversion 587 → 289; conversion_plan new 185; conversion_reuse new 76; conversion_write new 141; content_hash new 87; conversion_check new 67; conversion_record 386 → 463; conversion_issues 136 → 148; run_inputs 575 → 575; ome_tiff_copy 152 → 158; cli_run 452 → 464; run/preflight 370 → 399. tools/code_rules_baseline.json is unchanged and the # noqa count is unchanged (no noqa lines in the diff). The new modules are declared in pyproject.toml layers.

Notes for review

Independent review (418bbbd)

  • Folder links in an OME-Zarr copy: os.walk does not enter a link to a folder, so the files behind one were left out of the copy's SHA-256 (a change there kept the hash). content_sha256 now raises OSError for such a folder, and the copy counts as changed. The conversion itself writes no links.
  • copy_changed_after_conversion: the run continues with this warning, but RunInputs now refuses a run on a changed or added copy. The warning no longer suggests analysing such a copy: it says the bricks will refuse it and to convert again (contract §17, user guide).
  • Two regression tests, both failing on 792a6c1. Full gate: 1780 passed, 1 skipped (test_discovery.py:665, root).

Tests

ruff check ., ruff format --check ., python tools/check.py --rules: pass. pytest with .[dev,notebook,zarr]: 1778 passed, 1 skipped (at 792a6c1; 1780 after the review) (the skip: tests/test_discovery.py:665, running as root ignores file modes, expected), 1 warning (the existing reading array from closed file in test_planes).

https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc


Generated by Claude Code

…only)

conversion.py was at 587 of 600 lines. Move plan_conversion and PlannedCopy to
conversion_plan.py, the comparison with an earlier copy tree to
conversion_reuse.py and the .partial writing to conversion_write.py. No
behaviour changes; the private helpers used across modules lose their
underscore, and the tests patch write_named_copy and os.replace where they are
now used.

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

- #96: plan_conversion fingerprints each image before and after its header
  is read (preflight(bind_headers=True)) and refuses an image whose two
  fingerprints differ (source_changed_during_check, nothing written); that
  fingerprint is the source's in the record and is checked before and after
  the copy is written. The copy's pixel type comes from the same header
  (write_named_copy no longer reopens it) and a plane of another type is
  refused, never cast.
- #87: the conversion record (version 2) gives each copy's SHA-256
  (content_hash, one pass after writing); reuse, review_conversion and
  RunInputs.open (conversion_check, once at open for the recorded images)
  verify it. A version 1 record is refused with a message to convert again.
- #88: CopyEntry records the copy's channel names and significant bits and
  same_plan() compares them.
- #92: same_plan() compares the unknown sizes' reasons, not only their roles.
- #80: parse_size is exact (Fraction): a size that is not a whole number of
  bytes above 0 ("1.5", "0.5B") is refused while the command line is read.

Docs: contract §9.1 and §17, plan question 59 (16)-(18) and §10, user guide,
AGENTS.md module map.

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

A copy whose SHA-256 cannot be computed (permission denied, say) is not
known to be unchanged, so reuse, review_conversion and RunInputs treat it as
changed instead of raising. The record-version check no longer hashes the
recorded value (a list there raised TypeError instead of
ConversionRecordError).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc
@guijacquemet
guijacquemet marked this pull request as ready for review November 2, 2026 16:16
…bricks will refuse

- content_hash: os.walk does not enter a link to a folder, so an OME-Zarr copy holding one
  was hashed without the files behind it (a change there kept the SHA-256). Such a folder
  now raises OSError, so the copy counts as changed (the conversion writes no links).
- copy_changed_after_conversion: the run goes on, but RunInputs now refuses a run on a
  changed or added copy, so the warning no longer suggests analysing it; it says the
  bricks will refuse it and to convert again (contract §17, user guide).

Regression tests fail on 792a6c1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc
@guijacquemet
guijacquemet merged commit 6ac81ad into main Nov 2, 2026
7 checks passed
@guijacquemet
guijacquemet deleted the claude/pp-11c-conversion-integrity branch November 2, 2026 16:59
@guijacquemet
guijacquemet restored the claude/pp-11c-conversion-integrity branch November 2, 2026 16:59
guijacquemet pushed a commit that referenced this pull request Nov 2, 2026
docs/plan.md: main's items (16) to (18) of question 59 come first, then
question 60 of this PR.

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

Copy link
Copy Markdown
Contributor Author

Independent review: merged as 6ac81ad, a merge commit with head 418bbbd. The branch was auto-deleted and has been restored on 418bbbd.

What I checked in the code, with my own scripts as well as the tests

Findings, fixed in 418bbbd (both regression tests fail on 792a6c1)

  1. An OME-Zarr copy containing a link to a folder was hashed without the files behind the link, because os.walk does not follow it. I reproduced this: the hash stayed the same after a change behind the link. Such a copy now raises OSError and counts as changed.
  2. copy_changed_after_conversion is a warning and the run goes on, but RunInputs now refuses that run. I reproduced this with an added copy. The warning still said "check that these images are the ones you want analysed". It now says the bricks will refuse the run and to convert again. Contract §17 and the user guide are updated.

Gate (own worktree, Python 3.12, .[dev,notebook,zarr]): ruff check and ruff format are clean, and tools/check.py --rules passes. pytest gives 1780 passed, 1 skipped (test_discovery.py:665, running as root) and 1 warning (an existing one). CI is green on all 7 jobs.

§9 figures (files touched in src)

  • Largest file: run_inputs.py, 575 lines.
  • Largest function: run_command, 51 executable lines (unchanged).
  • Highest complexity: run_command 10; the highest new one is write_all at 8.
  • Most parameters: convert_experiment 7 (unchanged); the most in a new or changed function is preflight at 6.
  • Most statements: 30.
  • The baseline is unchanged and the # noqa count in src is 49 on both main and the branch. Layers are declared for content_hash and conversion_check (io).
  • The split commit f8cb39a only moves code (checked with --color-moved).

Issues: only #80 closed automatically, because GitHub links just the first number after "Fixes". I am closing #87, #88, #92 and #96 by hand, naming this merge.


Generated by Claude Code

guijacquemet pushed a commit that referenced this pull request Nov 2, 2026
Conflicts resolved hunk by hunk:
- conversion_plan.py: PP-11c's plan_conversion (epoch binding, dtype, _copied) kept as
  merged; PP-11b's copy_folder_issues, ConversionPreview and preview_conversion added
  after it, with their imports.
- conversion.py: PP-11c's split kept; PP-11b's ConversionResult.unknown_sizes and the call
  to copy_folder_issues (its _folder_issues removed) applied.
- pyproject.toml: PP-11c's layers plus notebook_convert.
- AGENTS.md, contract §17, plan §10 and question 59: both PRs' text (PP-11c marked
  implemented in #126; PP-11b's notebook row replaces "Not yet").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc
guijacquemet pushed a commit that referenced this pull request Nov 2, 2026
…rrangement

test_an_ome_zarr_copy_changed_in_place_is_not_reused (from #126) wrote a
'zyx' store into an experiment confirmed ZYX -> ZXY: the same letters under
contradicted roles, which issue #72 refuses (ome_zarr_axes_mismatch). The
store is now 'tzyx' with one time point, matched by its declared roles, so
the test checks the reuse of a changed chunk as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc
guijacquemet pushed a commit that referenced this pull request Nov 2, 2026
docs/plan.md: main's PP-11b and PP-11c items first, then this PR's links
item; this PR's questions 60 and 61 become 61 and 62, after main's question
60 (OME-Zarr axes and calibration, #129). docs/contract.md: both additions to
the error row kept (main's OME-Zarr codes, this PR's link codes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc
@guijacquemet
guijacquemet deleted the claude/pp-11c-conversion-integrity branch November 14, 2026 08:20
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.

[Sad Catapella B3][Validation] ConversionSettings accepts noninteger numeric inputs that fail downstream

2 participants