Skip to content

fix(metadata): validate codec chains against the threaded chunk spec - #4352

Open
d-v-b wants to merge 14 commits into
zarr-developers:mainfrom
d-v-b:fix/codec-chain-validation
Open

d-v-b wants to merge 14 commits into
zarr-developers:mainfrom
d-v-b:fix/codec-chain-validation

Conversation

@d-v-b

@d-v-b d-v-b commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

This AI-authored PR ensures that codec chains validate against the chunk shape emitted by the preceding codec, not the original chunk grid.

🤖 AI text below 🤖

Codec validation needs the geometry and dtype produced by preceding codecs. In particular, transpose followed by sharding must check divisibility against the transposed chunk sizes. Validating against the original grid can accept chains that read back incorrect data or reject valid chains. The same validation is needed inside sharding.

evolve_and_validate_codecs threads chunk specs through evolution, validation, and metadata resolution. Inner sharding chains use the actual inner spec, including its fill value.

Geometry is threaded as a whole chunk grid

Following zarrs (encoded_chunk_grid / chunk-local grids), codecs describe how they map the chunk grid through a new BaseCodec.resolve_chunk_grid(shape=..., chunk_grid=...) method, and validation carries the grid through the chain rather than enumerating chunk shapes. Cost is proportional to the number of codecs and dimensions, independent of how many distinct chunk shapes a rectilinear grid has.

  • Codecs that do not override resolve_metadata declare the identity by default.
  • CastValue, ScaleOffset and the numcodecs Delta, FixedScaleOffset and AsType declare the identity explicitly (they change dtype or fill value, never shape).
  • TransposeCodec permutes the shape and the per-axis edge lists.
  • A declaration is ignored if a subclass overrides resolve_metadata without the hook, or if it disagrees with resolve_metadata on the representative chunk.

Trade-off: chunk-local validation

A codec that overrides resolve_metadata and returns None from resolve_chunk_grid (the default) is still validated exactly on a regular grid, since every chunk has one shape. On a rectilinear grid it makes the rest of the chain chunk-local: later codecs are validated against one representative chunk (largest edge per axis). Rejections are always correct, but acceptance is incomplete: a chain that is invalid only for some other chunk shape is accepted at creation and fails when such a chunk is first encoded or decoded.

To make that failure an error rather than corruption, ShardingCodec._get_chunks_per_shard now checks divisibility at run time (previously it floor-divided; with the check removed, a non-dividing shard writes and reads back wrong rows). Codecs whose correctness depends on chunk shape must do the same.

The trade-off is documented in the docstrings of evolve_and_validate_codecs, resolve_chunk_grid and the sharding check, in a new "Chunk geometry and validation" section of the extending guide, in the rectilinear sharding docs, and in the changelog entry.

Tests

Accepted and rejected transpose/sharding chains (also behind Delta), reshape followed by transpose, nested sharding, rectilinear divisibility, actual fill values, data and metadata round trips. Chunk-local behaviour is pinned from both sides: an undeclared filter on a rectilinear grid still rejects a shard shape the representative chunk fails, accepts one that only a smaller chunk fails and then raises on the first write to that shard, and does not enumerate chunks to find a shape-changing codec's error. A table test covers when a declared grid is trusted or ignored. The bounded-call-count regression now includes Delta and an undeclared filter chains at ranks 4 and 12.

Validation: full suite 8806 passed, 1291 skipped, 4 xfailed; pre-commit hooks incl. mypy pass. A 3-d rectilinear grid with 100 distinct edges per axis and (Delta, Bytes, Zstd) took ~3.8 s per metadata construction with the previous streaming fallback; now ~0 s.

Known, separate issue (not fixed here): create_codec_pipeline evolves the pipeline against a placeholder all-ones chunk shape on rectilinear grids, which breaks any codec whose resolve_metadata depends on chunk shape, on main too.

Supersedes d-v-b#303.

🤖 Generated with Claude Code

d-v-b and others added 8 commits September 12, 2026 21:11
`ArrayV3Metadata` validated every codec against the array-level shape and
chunk grid, and threaded the *array* spec (not a chunk spec) through
`resolve_metadata` during evolution. Both wrongly reject chains in which an
earlier array->array codec changes a chunk's shape or rank, e.g. the
zarr-extensions `reshape` codec followed by `transpose` with an order of the
reshaped rank -- a combination the reshape spec explicitly endorses and that
the encode path already handles correctly.

Codecs are now evolved and validated in a single threaded pass
(`evolve_and_validate_codecs`): each codec sees the chunk spec produced by
the previous codec's `resolve_metadata`, exactly as at encode time. The
array-level shape/chunk grid are passed to `Codec.validate` unchanged until a
codec changes the chunk shape, after which the resolved chunk shape (and a
regular grid of it) stands in for them. `ShardingCodec.validate` now validates
its inner chain the same way against the inner chunk shape.

Assisted-by: ClaudeCode:claude-fable-5
…rectilinear chunk shape

Review feedback on the threaded-chunk-spec validation: after a codec changes
the chunk shape, validating the rest of the chain against a single
representative (max-edge) chunk shape is unsound for rectilinear grids --
an inner shard size that divides the largest chunk need not divide the
others. Concretely, transpose over a rectilinear grid followed by sharding
falsely accepted an inner chunk shape that only divided the largest
transposed chunk. The representative was also used to *detect* shape
changes, which could miss changes affecting only non-representative chunks.

`evolve_and_validate_codecs` now threads every distinct chunk shape of the
grid (the cross product of per-dimension distinct edges, capped at 4096 with
a ZarrUserWarning on truncation) through `resolve_metadata`, and validates
each one individually once any codec has changed a chunk shape. The
representative spec remains the single spec used for codec evolution and
dtype tracking.

Assisted-by: ClaudeCode:claude-fable-5
…idation

Two hypothesis oracles over the threaded-chunk-spec validation:

- acceptance implies round-trip: any reshape of a chunk into a valid
  factorization followed by a transpose of the reshaped rank (with and
  without sharding) is accepted, encodes/decodes losslessly, and its
  metadata survives JSON serialization; a transpose order of any other
  rank is rejected.

- transpose-then-shard over a rectilinear grid is accepted exactly when
  every chunk shape in the grid, transposed, is divisible by the inner
  shard shape (verified against a brute-force cross-product oracle; this
  test fails on the max-edge-representative implementation).

Assisted-by: ClaudeCode:claude-fable-5
Validate inner codecs during evolution, where the actual fill value is
available, and cover fill-changing chains with a round-trip property.

Assisted-by: Codex:GPT-6
Review fixes for the threaded codec-chain validation:

- Attach a note to any exception raised by a codec's evolve_from_array_spec,
  validate or resolve_metadata naming the codec's position in the chain, its
  class and the shape it was checked against. Codec messages talk about "the
  array", which is misleading once an earlier codec has changed the chunk
  shape; the exception type and message are unchanged.
- Drop the unused `evolve` parameter of evolve_and_validate_codecs left over
  from the removed ShardingCodec.validate inner-chain check.
- Document in ShardingCodec.evolve_from_array_spec why the inner chain is
  validated there (validate has no fill value) and against which grid.
- Docstrings use single backticks; the towncrier fragment is 303.bugfix.md.
- Tests: one parametrized happy-path test for rectilinear grids plus one
  test per error case; the sharding inner-chain test asserts the note.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s the transposed chunk

Add the `transposed_sharding_chains` strategy: a codec chain with a
TransposeCodec (random permutation) ahead of a ShardingCodec in one of three
layouts (transpose then shard; nested shard with the transpose between the
levels; transpose inside a shard as the always-valid control), together with
an oracle for its validity (every edge of the sharding codec's chunk shape
divides the transposed edge it applies to). Half the drawn chains are
invalid, breaking exactly one axis.

The property asserts that create_array accepts a chain exactly when the
oracle says it is valid, and that an accepted chain round-trips its data and
its persisted metadata. On main before the fix it fails two ways: a nested
sharding codec's inner chain is never validated, so an invalid inner chunk
shape is accepted and reads back wrong data, and a valid transpose-then-shard
chain is rejected because the sharding codec was validated against the
untransposed chunk grid.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rom chain validation

Apply the second-lens review cuts:

- Enumerate every distinct rectilinear chunk shape without a cap. The
  warn-and-continue path validated only a subset of shapes and then accepted
  the metadata, which is worse than either failing or checking everything.
- Drop the exception notes and the try/except blocks around evolve, validate
  and resolve_metadata; they were not part of the fix.
- Move `transposed_sharding_chains` out of the public `zarr.testing.strategies`
  module into `tests/test_properties.py`, its only user.
- Drop the metadata-only rectilinear oracle property; the create_array plus
  round-trip property in `test_properties.py` covers the same acceptance
  oracle for regular and nested sharding.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@read-the-docs-community

read-the-docs-community Bot commented Sep 14, 2026

Copy link
Copy Markdown

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.96970% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.35%. Comparing base (0de6077) to head (55c8bfa).

Files with missing lines Patch % Lines
src/zarr/abc/codec.py 75.00% 1 Missing ⚠️
src/zarr/core/metadata/v3.py 97.56% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4352      +/-   ##
==========================================
+ Coverage   94.34%   94.35%   +0.01%     
==========================================
  Files          92       92              
  Lines       12935    12981      +46     
==========================================
+ Hits        12203    12248      +45     
- Misses        732      733       +1     
Files with missing lines Coverage Δ
src/zarr/codecs/cast_value.py 99.36% <100.00%> (+<0.01%) ⬆️
src/zarr/codecs/numcodecs/_codecs.py 95.60% <100.00%> (+0.14%) ⬆️
src/zarr/codecs/scale_offset.py 100.00% <100.00%> (ø)
src/zarr/codecs/sharding.py 96.25% <100.00%> (+<0.01%) ⬆️
src/zarr/codecs/transpose.py 92.30% <100.00%> (+0.78%) ⬆️
src/zarr/core/array.py 98.08% <ø> (-0.01%) ⬇️
src/zarr/abc/codec.py 97.70% <75.00%> (-1.10%) ⬇️
src/zarr/core/metadata/v3.py 95.77% <97.56%> (+0.33%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Preserve grid geometry for identity, cast, scale, and transpose codecs. Stream arbitrary resolver chains without retaining the cross product, and test bounded work for common paths.

Assisted-by: Codex:GPT-6
@d-v-b
d-v-b marked this pull request as ready for review September 14, 2026 14:43
@d-v-b d-v-b added the benchmark Code will be benchmarked in a CI job. label Sep 14, 2026
@d-v-b

d-v-b commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

this PR has a performance impact for codec creation with rectilinear chunk grids, where we loop over every chunk shape defined in a grid, checking each one for compatibility with the entire codec chain. I'm working on a mitigation.

@codspeed-hq

codspeed-hq Bot commented Sep 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 113 untouched benchmarks
⏩ 37 skipped benchmarks1


Comparing d-v-b:fix/codec-chain-validation (2c56eed) with main (ba883a5)2

Open in CodSpeed

Footnotes

  1. 37 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (0de6077) during the generation of this report, so ba883a5 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

…umerating chunks

Validating codec chains on rectilinear grids streamed every combination of
per-axis chunk edges through each codec whose `resolve_metadata` was
overridden, e.g. numcodecs `delta`. A 3-d grid with 100 distinct edges per
axis took ~3.8 s per metadata construction, growing as n**ndim.

Follow zarrs: codecs map a whole chunk grid via the new
`BaseCodec.resolve_chunk_grid`. Dtype/fill-value codecs declare the
identity and transpose declares a permutation, so those chains stay exact.
An undeclared codec is exact on regular grids; on rectilinear grids it
makes the rest of the chain chunk-local, validated against one
representative chunk, with other chunk shapes checked at encode/decode
time. ShardingCodec now enforces divisibility at run time, which would
otherwise floor-divide and silently corrupt data.

Document the trade-off in the codec docstrings, the extending guide, the
rectilinear sharding docs and the changelog.

Assisted-by: ClaudeCode:claude-opus-5
…chunk shape

`create_codec_pipeline` evolved V3 pipelines against an all-ones placeholder
chunk spec whenever the grid was not regular, so any codec whose
`resolve_metadata` depends on the chunk shape (e.g. a reshape filter) failed
at array creation for rectilinear grids even though metadata validation had
accepted the chain, and shape-sensitive evolution output (sharding's inner
chain, BytesCodec endian) was computed against a meaningless shape.

Use `representative_chunk_shape`, the same shape `ArrayV3Metadata.__init__`
threads through evolution, so the pipeline carries exactly the codecs the
metadata produced. The ChunkTransform itself remains shape-agnostic: it only
holds the evolved codecs and resolves specs per call.

Assisted-by: ClaudeCode:claude-fable-5-1
@d-v-b
d-v-b requested a review from ilan-gold September 14, 2026 15:52
@d-v-b

d-v-b commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

requesting review because this adds to codec API

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

Labels

benchmark Code will be benchmarked in a CI job.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant