Skip to content

Commit b87fd36

Browse files
committed
fix(metadata): validate factored grids without chunk enumeration
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
1 parent aaefe61 commit b87fd36

3 files changed

Lines changed: 153 additions & 52 deletions

File tree

‎changes/4352.bugfix.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Codec chains are now validated against the chunk spec threaded through each codec's `resolve_metadata`, the same way the codec pipeline resolves it at encode time, instead of against the array-level shape. Previously, an `array -> array` codec that changes a chunk's shape or rank (such as the zarr-extensions `reshape` codec) followed by a codec whose configuration refers to the transformed chunk (e.g. `transpose`) was wrongly rejected. The sharding codec now validates its inner codec chain in the same way. Inner sharding chains are validated during evolution using the actual fill value, so valid fill-changing codecs are not rejected against a fabricated default.
1+
Validate codec chains against evolving chunk specs, including inner sharding chains and the actual fill value. Preserve factored grid geometry for default metadata resolvers, cast/scale codecs, and transpose so common chains validate per-axis sizes without enumerating chunk combinations. Stream arbitrary shape-changing chains without materializing the cross product.

‎src/zarr/core/metadata/v3.py‎

Lines changed: 80 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import itertools
44
import json
5-
from collections.abc import Iterable, Mapping, Sequence
5+
from collections.abc import Iterable, Iterator, Mapping, Sequence
66
from dataclasses import dataclass, field, replace
77
from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypeGuard, cast
88

99
from typing_extensions import TypedDict
1010

11-
from zarr.abc.codec import ArrayArrayCodec, ArrayBytesCodec, BytesBytesCodec, Codec
11+
from zarr.abc.codec import ArrayArrayCodec, ArrayBytesCodec, BaseCodec, BytesBytesCodec, Codec
1212
from zarr.abc.metadata import Metadata
1313
from zarr.core._json import json_to_buffer
1414
from zarr.core.array_spec import ArrayConfig, ArraySpec
@@ -132,27 +132,39 @@ def representative_chunk_shape(chunk_grid: ChunkGridMetadata) -> tuple[int, ...]
132132
Regular grids have exactly one chunk shape. Rectilinear grids have many;
133133
the largest edge along each dimension is used. This is only suitable where
134134
a single shape is structurally required (rank checks, codec evolution);
135-
size-sensitive validation must consider every distinct chunk shape, see
136-
`_distinct_chunk_shapes`.
135+
size-sensitive validation must cover every chunk size, either through the
136+
factored grid or by streaming `_distinct_chunk_shapes`.
137137
"""
138138
if isinstance(chunk_grid, RegularChunkGridMetadata):
139139
return chunk_grid.chunk_shape
140140
return tuple(s if isinstance(s, int) else max(s) for s in chunk_grid.chunk_shapes)
141141

142142

143-
def _distinct_chunk_shapes(chunk_grid: ChunkGridMetadata) -> list[tuple[int, ...]]:
143+
def _distinct_chunk_shapes(chunk_grid: ChunkGridMetadata) -> Iterator[tuple[int, ...]]:
144144
"""Every distinct chunk shape occurring in `chunk_grid`.
145145
146146
For a rectilinear grid every combination of per-dimension distinct edges
147147
occurs as an actual chunk shape (each edge along one dimension meets each
148148
edge along every other), so this is the full cross product.
149149
"""
150150
if isinstance(chunk_grid, RegularChunkGridMetadata):
151-
return [chunk_grid.chunk_shape]
151+
yield chunk_grid.chunk_shape
152+
return
152153
per_dim = (
153154
(s,) if isinstance(s, int) else tuple(dict.fromkeys(s)) for s in chunk_grid.chunk_shapes
154155
)
155-
return list(itertools.product(*per_dim))
156+
yield from itertools.product(*per_dim)
157+
158+
159+
def _resolved_chunk_specs(
160+
chunk_grid: ChunkGridMetadata, spec: ArraySpec, codecs: tuple[Codec, ...]
161+
) -> Iterator[ArraySpec]:
162+
"""Stream chunk specs through a codec prefix without storing the cross product."""
163+
for shape in _distinct_chunk_shapes(chunk_grid):
164+
current = replace(spec, shape=shape)
165+
for codec in codecs:
166+
current = codec.resolve_metadata(current)
167+
yield current
156168

157169

158170
def evolve_and_validate_codecs(
@@ -162,59 +174,76 @@ def evolve_and_validate_codecs(
162174
chunk_grid: ChunkGridMetadata,
163175
chunk_spec: ArraySpec,
164176
) -> tuple[Codec, ...]:
165-
"""Evolve and validate a codec chain, threading the chunk spec.
166-
167-
Each codec is evolved and validated against the chunk spec produced by the
168-
previous codec's `resolve_metadata`, the same spec it will see at
169-
encode/decode time, not against the array-level metadata. Earlier
170-
array->array codecs may change the dtype (`cast_value`) or the shape and
171-
even the rank of a chunk (the `reshape` extension codec, which the spec
172-
explicitly allows to be followed by `transpose`).
173-
174-
`shape` and `chunk_grid` are the array-level values passed to
175-
`Codec.validate`. They are handed unchanged to every codec until one
176-
changes the shape of any chunk; from then on the array-level values are no
177-
longer meaningful for the remaining codecs. Because `validate` checks may
178-
be size-sensitive (sharding divisibility), every *distinct* chunk shape of
179-
the grid is threaded through `resolve_metadata` and validated
180-
individually; for a rectilinear grid, a single representative shape would
181-
not be sound: an inner chunk size that divides the largest chunk need not
182-
divide the others. Each threaded shape is presented to `validate` as a
183-
regular grid of that shape, the only shape-related facts that survive a
184-
per-chunk transformation.
185-
186-
`chunk_spec` (built from the representative chunk shape) is threaded
187-
separately as the single spec used for codec evolution and dtype tracking,
188-
since evolution must produce one codec chain.
189-
190-
Per-codec `validate` runs before `resolve_metadata`, since the latter
191-
may rely on invariants the former checks (e.g. `cast_value` rejects
192-
complex source dtypes that would otherwise crash `_do_cast`).
177+
"""Evolve and validate codecs while preserving factored chunk geometry.
178+
179+
Codecs with the default identity metadata resolver retain the grid.
180+
CastValue and ScaleOffset also retain its geometry; TransposeCodec permutes
181+
the dimensions of both the shape and grid. These paths resolve one spec per
182+
codec and validate the grid directly, without enumerating chunk combinations.
183+
Only the exact built-in classes use specialized geometry rules; overridden
184+
metadata resolvers must use the general path.
185+
186+
Arbitrary metadata resolvers stream each distinct chunk spec. Validation
187+
uses the array geometry until a chunk shape changes, then each resolved
188+
shape is validated as a regular grid. Full specs retain per-chunk dtype and
189+
fill values. Prefixes are replayed for subsequent codecs rather than cached:
190+
this bounds memory but may still require combinatorial work for arbitrary
191+
transforms. Evolution uses one representative spec to produce one chain.
192+
193+
Validation precedes metadata resolution, whose implementation may require
194+
the validated dtype and geometry. Each codec is evolved only once here.
193195
"""
196+
from zarr.codecs.cast_value import CastValue
197+
from zarr.codecs.scale_offset import ScaleOffset
198+
from zarr.codecs.transpose import TransposeCodec
199+
194200
out: list[Codec] = []
195201
spec = chunk_spec
196-
threaded = _distinct_chunk_shapes(chunk_grid)
202+
grid = chunk_grid
203+
validation_shape = shape
204+
fallback_grid: ChunkGridMetadata | None = None
205+
fallback_spec = chunk_spec
206+
fallback_codecs: tuple[Codec, ...] = ()
197207
shapes_changed = False
198208
for codec in codecs:
199209
evolved = codec.evolve_from_array_spec(spec)
200-
# The array-level shape and grid are handed to validate until a codec
201-
# changes chunk shapes; from then on each distinct chunk shape is
202-
# validated as its own regular grid.
203-
if shapes_changed:
204-
for s in threaded:
210+
preserves_shape = type(evolved).resolve_metadata is BaseCodec.resolve_metadata or type(
211+
evolved
212+
) in (CastValue, ScaleOffset)
213+
if fallback_grid is None and (preserves_shape or type(evolved) is TransposeCodec):
214+
evolved.validate(shape=validation_shape, dtype=spec.dtype, chunk_grid=grid)
215+
spec = evolved.resolve_metadata(spec)
216+
if type(evolved) is TransposeCodec:
217+
order = evolved.order
218+
validation_shape = tuple(validation_shape[d] for d in order)
219+
if isinstance(grid, RegularChunkGridMetadata):
220+
grid = RegularChunkGridMetadata(
221+
chunk_shape=tuple(grid.chunk_shape[d] for d in order)
222+
)
223+
else:
224+
grid = RectilinearChunkGridMetadata(
225+
chunk_shapes=tuple(grid.chunk_shapes[d] for d in order)
226+
)
227+
else:
228+
if fallback_grid is None:
229+
fallback_grid, fallback_spec = grid, spec
230+
changed = False
231+
for current in _resolved_chunk_specs(fallback_grid, fallback_spec, fallback_codecs):
205232
evolved.validate(
206-
shape=s, dtype=spec.dtype, chunk_grid=RegularChunkGridMetadata(chunk_shape=s)
233+
shape=current.shape if shapes_changed else validation_shape,
234+
dtype=current.dtype,
235+
chunk_grid=(
236+
RegularChunkGridMetadata(chunk_shape=current.shape)
237+
if shapes_changed
238+
else grid
239+
),
207240
)
208-
else:
209-
evolved.validate(shape=shape, dtype=spec.dtype, chunk_grid=chunk_grid)
241+
resolved = evolved.resolve_metadata(current)
242+
changed |= resolved.shape != current.shape
243+
shapes_changed |= changed
244+
fallback_codecs += (evolved,)
245+
spec = evolved.resolve_metadata(spec)
210246
out.append(evolved)
211-
resolved = list(
212-
dict.fromkeys(evolved.resolve_metadata(replace(spec, shape=s)).shape for s in threaded)
213-
)
214-
if resolved != threaded:
215-
shapes_changed = True
216-
threaded = resolved
217-
spec = evolved.resolve_metadata(spec)
218247
return tuple(out)
219248

220249

‎tests/test_codecs/test_codec_chain_validation.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,75 @@ def test_rectilinear_every_chunk_shape_validated() -> None:
186186
pytest.raises(ValueError, match="not\\s+divisible"),
187187
):
188188
_rectilinear_transpose_sharding_metadata((5, 3))
189+
190+
191+
@pytest.mark.parametrize("rank", [4, 12])
192+
@pytest.mark.parametrize("chain", ["bytes", "scale-offset", "transpose-sharding"])
193+
def test_factored_validation_does_not_visit_chunk_combinations(
194+
rank: int, chain: str, monkeypatch: pytest.MonkeyPatch
195+
) -> None:
196+
"""Common codec chains resolve specs independently of the chunk cross product."""
197+
from zarr.abc.codec import BaseCodec
198+
from zarr.codecs.scale_offset import ScaleOffset
199+
200+
calls = 0
201+
original = BaseCodec.resolve_metadata
202+
203+
def counted(self: Any, spec: ArraySpec) -> ArraySpec:
204+
nonlocal calls
205+
calls += 1
206+
assert calls <= 10, "metadata validation enumerated chunk combinations"
207+
return original(self, spec)
208+
209+
monkeypatch.setattr(BaseCodec, "resolve_metadata", counted)
210+
codecs: tuple[Any, ...]
211+
if chain == "bytes":
212+
codecs = (BytesCodec(),)
213+
elif chain == "scale-offset":
214+
codecs = (ScaleOffset(offset=0), BytesCodec())
215+
else:
216+
codecs = (
217+
TransposeCodec(order=tuple(reversed(range(rank)))),
218+
ShardingCodec(chunk_shape=(1,) * rank),
219+
)
220+
with zarr.config.set({"array.rectilinear_chunks": True}):
221+
ArrayV3Metadata(
222+
shape=(3,) * rank,
223+
data_type=Int32(),
224+
chunk_grid=RectilinearChunkGridMetadata(chunk_shapes=((1, 2),) * rank),
225+
chunk_key_encoding={"name": "default"},
226+
fill_value=0,
227+
codecs=codecs,
228+
attributes={},
229+
dimension_names=None,
230+
)
231+
assert calls <= 10
232+
233+
234+
def test_arbitrary_shape_validation_stops_at_first_invalid_chunk(
235+
monkeypatch: pytest.MonkeyPatch,
236+
) -> None:
237+
"""A failing arbitrary transform does not consume all chunk combinations."""
238+
import itertools
239+
240+
original_product = itertools.product
241+
242+
def guarded_product(*args: Any) -> Iterator[tuple[int, ...]]:
243+
yield next(original_product(*args))
244+
raise AssertionError("consumed combinations after the first invalid chunk")
245+
246+
monkeypatch.setattr(itertools, "product", guarded_product)
247+
with (
248+
zarr.config.set({"array.rectilinear_chunks": True}),
249+
pytest.raises(ValueError, match="cannot reshape a chunk"),
250+
):
251+
ArrayV3Metadata(
252+
shape=(3, 3),
253+
data_type=Int32(),
254+
chunk_grid=RectilinearChunkGridMetadata(chunk_shapes=((1, 2), (1, 2))),
255+
chunk_key_encoding={"name": "default"},
256+
fill_value=0,
257+
codecs=(ReshapeCodec(shape=(4,)), BytesCodec()),
258+
attributes={},
259+
dimension_names=None,
260+
)

0 commit comments

Comments
 (0)