Skip to content

feat(zarr-metadata): composition rules layer, shape-exact codec guards, typed builders - #296

Open
d-v-b wants to merge 42 commits into
mainfrom
claude/zarr-metadata-typeddict-builder-cd5f75
Open

feat(zarr-metadata): composition rules layer, shape-exact codec guards, typed builders#296
d-v-b wants to merge 42 commits into
mainfrom
claude/zarr-metadata-typeddict-builder-cd5f75

Conversation

@d-v-b

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

Copy link
Copy Markdown
Owner

🤖 AI text below 🤖

Ships the reframed scope for the metadata package: in addition to code that models the structure of the literal JSON, the package now ships code that models the rules governing composition of the elements of a full metadata document. The package's layering becomes one contract per layer:

  1. zarr_metadata.model — structure, element by element; never interprets extension points
  2. zarr_metadata.rules (new) — composition across the document; rules are data, keyed by the keys they read
  3. zarr_metadata.builder (new) — construction conveniences that apply layers 1 + 2

The composition layer

zarr_metadata.rules exports the engine (Rule, applicable, run_rules), per-document rule sets, and read-side validate_* / is_* / parse_* trios that mirror the model grammar with a stronger judgment: structure and composition, every problem reported together with loc-precise diagnostics. This is the front door for readers — "is this loaded zarr.json a document I should act on?" — which previously had no blessed path. The is_* functions deliberately return bool, not TypeIs: a composition-invalid document is still an instance of the TypedDict, so only the structural layer can narrow honestly.

ZARR_V3_ARRAY_RULES (twelve rules): fill value ↔ data type (every dtype family, drift-tested), codec pipeline kind ordering (AA* AB BB*, exactly one AB, inconclusive in the presence of unknown codecs), known-name shapes, dimension_names counts, chunk-grid values (positive extents) and geometry (regular rank; rectilinear rank + per-dimension chunk-size sums incl. RLE pairs), transpose orders (self-permutation at any depth, rank agreement with shape), and sharding — inner codecs/index_codecs are judged as pipelines recursively at every nesting depth, and inner chunk shapes must be positive, rank-matched, and evenly divide the enclosing chunk, recursively. New ZARR_V2_ARRAY_RULES (chunks/shape rank) and ZARR_V3_GROUP_RULES (inline consolidated metadata recurses, judging each embedded child by its own rules at its path).

Boundary change: two composition checks that had leaked into the structural validator (v3 dimension_namesshape, v2 chunksshape) moved into the rules layer. model parsers and dataclasses now accept those documents — they are lossless, structurally well-formed representations of what a store may contain — and the move kills the bug where one dimension_names mismatch was reported twice in different words.

Strictness stance, now documented on the package: zarr_metadata models canonical documents and is deliberately stricter than any given implementation (it rejects fill_value: 5.0 for uint8; zarr-python coerces it). Implementations coerce ambiguous input as they see fit and then validate the canonical result — disagreement in that direction is the contract, not drift.

Shape-exact codec classification

zarr_metadata.v3.codec.kind ships branded per-kind unions and TypeIs guards. An adversarial review found the object-form guards unsound — is_array_array_codec({"name": "transpose"}) narrowed a dict with no configuration to TransposeCodecObject (reading the narrowed value raised KeyError), and is_known_codec({}) itself raised. TypeIs narrowing is two-sided, so the fix is exactness: a new v3._shape module holds one type-level validator per known codec and chunk grid, derived from the TypedDicts' __annotations__/__required_keys__ (never restated), and both the guards and the rules' spelling checks consume it. That single mechanism also closed the config-value hole: {"name": "gzip", "configuration": {"level": "high"}}, endian: "middle", blosc cname: "nope", and unexpected configuration keys are now rejected instead of passing a key-presence-only check. Drift tests tie every codec/grid/data-type module to its judgment registry, so an unregistered addition fails a test rather than silently suppressing the exactly-one-array→bytes check.

Construction layer

  • create_* factories — one per public document TypedDict, **kwargs: Unpack[<TypedDict>], enforced by a drift test. At literal-keyword call sites missing required keys and wrong value types are static errors; the docs are explicit that **-splat bypasses that coverage, and the runtime pass (normalize → structure → composition, one combined raise) exists for exactly those callers. The strict on-disk .zarray/.zgroup factories reject attributes at runtime (previously a splatted attributes came back typed as a shape that forbids it), and the v2 consolidated factory validates the .zmetadata envelope instead of validating nothing.
  • ZarrV3ArrayMetadataBuilder — immutable incremental accumulation for staged assembly: eager rule firing whenever a rule's dependencies are present, unconstrained field order, cross-call conflicts naming both fields, T | UNSET properties on the PEP 661 sentinel, no shared mutable state, no build_unchecked. Scoped honestly: v3 arrays only, the document type with the richest cross-field coupling.

Review provenance

The branch was attacked by four independent adversarial reviewers (premise, static-typing claims, validation soundness, maintenance cost) plus roborev. Everything they confirmed is fixed here: the TypeIs unsoundness, the drifted "regular" literal, malformed r<N> dtype names escaping judgment, the .zarray/.zgroup type-laundering, the do-nothing v2 consolidated factory, the double-reported dimension mismatch, the nine accepted-invalid documents, and the overclaimed "strictly stronger than runtime" static story. One notable non-finding: 30k mutated documents through the one-shot and incremental construction paths found zero verdict divergence — the eager rule engine is monotone by construction.

Verified: just check green throughout — ruff, pyright 1.1.404 strict (0 errors), 734 tests, strict docs build (new API pages for rules and builder).

Known follow-up: v2 fill-value/dtype consistency (NumPy dtype grammar) has no rule yet; front-door re-export of rules names is an open curation decision.

🤖 Generated with Claude Code

dependabot Bot and others added 30 commits May 31, 2026 19:28
…#176)

Bumps the actions group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [prefix-dev/setup-pixi](https://github.com/prefix-dev/setup-pixi) | `0.9.5` | `0.9.6` |
| [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `6.0.1` |
| [github/issue-metrics](https://github.com/github/issue-metrics) | `4.2.2` | `4.2.7` |
| [j178/prek-action](https://github.com/j178/prek-action) | `2.0.3` | `2.0.4` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.0` | `7.0.1` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `7.0.0` | `8.0.1` |
| [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.13.0` | `1.14.0` |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.5.3` | `0.5.6` |



Updates `prefix-dev/setup-pixi` from 0.9.5 to 0.9.6
- [Release notes](https://github.com/prefix-dev/setup-pixi/releases)
- [Commits](prefix-dev/setup-pixi@1b2de7f...5185adf)

Updates `codecov/codecov-action` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](codecov/codecov-action@57e3a13...e79a696)

Updates `github/issue-metrics` from 4.2.2 to 4.2.7
- [Release notes](https://github.com/github/issue-metrics/releases)
- [Commits](github-community-projects/issue-metrics@c9e9838...1e38d5e)

Updates `j178/prek-action` from 2.0.3 to 2.0.4
- [Release notes](https://github.com/j178/prek-action/releases)
- [Commits](j178/prek-action@6ad8027...bdca6f1)

Updates `actions/upload-artifact` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v7...043fb46)

Updates `actions/download-artifact` from 7.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](actions/download-artifact@v7...3e5f45b)

Updates `pypa/gh-action-pypi-publish` from 1.13.0 to 1.14.0
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](pypa/gh-action-pypi-publish@v1.13.0...cef2210)

Updates `zizmorcore/zizmor-action` from 0.5.3 to 0.5.6
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](zizmorcore/zizmor-action@b1d7e1f...5f14fd0)

---
updated-dependencies:
- dependency-name: prefix-dev/setup-pixi
  dependency-version: 0.9.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/issue-metrics
  dependency-version: 4.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: j178/prek-action
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix: byte-order handling for structured dtypes in the bytes codec

The bytes codec neither byte-swapped structured-dtype fields to its
configured endian on encode (numpy reports byteorder '|' for void
dtypes, so the top-level byteorder comparison never detected a
mismatch) nor honored its endian when decoding, silently corrupting
any structured data whose field byte order differed from the stored
one (e.g. virtual references to external big-endian data).

Encode now detects byte-order mismatches by comparing full dtypes via
newbyteorder, and decode reinterprets raw bytes in the stored byte
order before converting to the data type's declared byte order, so the
stored layout (codec state) and the in-memory layout (array data type)
are independent.

Closes zarr-developers#4141

Assisted-by: ClaudeCode:claude-fable-5

* test: fold structured byte-order cases into existing bytes codec tests

Extend test_endian's parametrization with structured dtypes and
test_bytes_codec_sync_roundtrip with endian/dtype parametrization plus
stored-layout and decoded-dtype assertions, instead of adding parallel
test functions for the same properties.

Assisted-by: ClaudeCode:claude-fable-5

* refactor: rename stored_dtype to view_dtype in BytesCodec decode

The variable is the dtype used to view the raw chunk bytes (byte order
from the codec's endian configuration), not a property of the stored
data or of the returned buffer, which always carries the array's
declared dtype.

Assisted-by: ClaudeCode:claude-fable-5

* docs: note that the decode-side byte-order conversion copies the chunk

Assisted-by: ClaudeCode:claude-fable-5
…3MetadataFieldJSON

Three coordinated typing changes so that the package's canonical codec /
chunk-grid / chunk-key-encoding / data-type TypedDicts satisfy the fields
they describe (codecs, chunk_grid, data_type, ...), which previously
failed on invariance and open-TypedDict inference:

- ZarrV3NamedConfigJSON: name and configuration become ReadOnly (PEP 705)
  and the envelope becomes closed (PEP 728), making it usable as a
  JSONValue (required for e.g. sharding_indexed inner codec lists).
- Every concrete *Object / *Configuration TypedDict becomes closed, and
  object forms declare must_understand: NotRequired[bool].
- zarr_metadata.pydantic serializers route return_type through the
  _pydantic_schema shadow types so schema generation stays warning-free;
  ZarrV3NamedConfig.to_json builds its dict in one literal since
  configuration is now ReadOnly.

This unblocks TypeIs-based codec classification (the narrowed type of a
type guard must be assignable to its input type) and a builder whose
evolve() accepts the concrete entity types.

Assisted-by: ClaudeCode:claude-opus-5
Adds zarr_metadata.v3.codec.kind: branded unions over the concrete codec
types for the spec's three pipeline kinds, plus TypeIs guards that
classify a codec entry by name. Bare short-hand names are only claimed
when the codec's canonical type permits the bare form, keeping the
narrowing sound. Unknown codecs answer False to every guard.

Assisted-by: ClaudeCode:claude-opus-5
…ules

Adds zarr_metadata.builder: an immutable accumulator over the plain JSON
TypedDict shapes. evolve() is the single fully-typed setter
(Unpack[ZarrV3ArrayMetadataJSONPartial]); extension fields go through
evolve_extension; without() unsets keys, preserving the UNSET-vs-null
distinction; properties answer T | UNSET; build() validates structurally
(via the model layer's parser) and semantically and returns the plain
document; to_partial_json() is the honest always-succeeds serializer.

Semantic rules are data keyed by field dependencies, firing eagerly over
merged state whenever dependency-complete, so field order is
unconstrained and conflicts attribute both fields. Initial rules:
fill_value/data_type compatibility, codec pipeline kind ordering (via
codec.kind TypeIs guards), dimension_names length, regular chunk grid
dimensionality. Unknown entities pass through per extension openness.

Assisted-by: ClaudeCode:claude-opus-5
Three defects found by adversarially exercising ZarrV3ArrayMetadataBuilder:

1. Mutable-state leak: the shape/dimension_names properties returned the
   internal object uncopied, so a list smuggled past the type checker
   could be mutated in place, corrupting the builder behind the eager
   rules' back. Builders now materialize JSON arrays as tuples at
   ingestion (arrays_to_tuples after the deep-copy), and every property
   deep-copies out. As a consequence, documents straight from json.loads
   normalize, and builder equality is list/tuple-spelling-insensitive.

2. Verdict flip across model normalization: the model layer collapses
   empty-config codecs to bare names, and bare "gzip" classified as an
   unknown extension - so a document the builder rejected round-tripped
   through ZarrV3ArrayMetadata into one the rules accepted. Pipeline
   ordering now classifies by name across every spelling via the new
   codec_kind_of_name (the TypeIs guards stay spelling-strict, since
   narrowing bare "transpose" to an object type would be unsound).

3. Known names in invalid spellings built successfully: bare "transpose"
   passed as an unknowable extension, suppressing both spelling checks
   and the exactly-one-array->bytes count, letting build() emit
   spec-invalid documents (including pipelines with no array->bytes
   codec, and config-less "regular" chunk grids). New spelling rules
   check known codec and chunk-grid names: bare short-hand only where
   the concrete type permits it, required configuration keys present -
   derived from the TypedDicts' __required_keys__, never restated by
   hand. Extension openness is unchanged for genuinely unknown names.

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

Add one `create_*` factory per public document TypedDict, each taking
`**kwargs: Unpack[<TypedDict>]`. Unpacking the total TypedDict makes a
missing required key a static error at the call site, which is stronger
than any runtime completeness check; at runtime each factory deep-copies
its inputs, materializes JSON arrays as tuples, runs the structural
validator and (for v3 arrays) the semantic rules, and raises a single
MetadataValidationError carrying every problem from both passes.

The rule is scoped to document TypedDicts. Entity TypedDicts get no
factory: constructor syntax already enforces their shape statically, no
semantic rule applies to an entity in isolation, and a do-nothing factory
would imply a validation that is not happening. DOCUMENT_FACTORIES is the
registry a drift test checks, so a new document type cannot ship without
its factory.

The open v3 array and group documents take extension fields through an
`extensions=` mapping rather than free keyword names, since type checkers
without PEP 728 reject unknown keywords statically; names that shadow
standard keys are rejected instead of merged, so a later pass judges the
standard field the caller actually passed.

This makes the factories the preferred surface for the common case where
every field is known at one call site. ZarrV3ArrayMetadataBuilder remains
for staged assembly across program points, where eager rule firing and
cross-call conflict attribution are what you want.

Assisted-by: ClaudeCode:claude-opus-5
Four fixes from an adversarial review of the branch:

- The regular-grid dimension rule matched the literal "regular" instead
  of REGULAR_CHUNK_GRID_NAME, a silent-drift risk right after the
  constant-renaming refactor.
- Malformed r<N> data type names (r12, r0) escaped judgment entirely:
  the fill-value rule declined ("a data_type problem") but nothing owned
  the data_type problem. The fill branch now defers to the canonical
  raw_bytes_dtype_name validator, and a new data_type spelling rule
  reports names that misspell the known raw-bytes family rather than
  letting them masquerade as unknown extensions.
- create_zarr_v2_z_array_json / create_zarr_v2_z_group_json accepted a
  **-splatted `attributes` key and returned it under strict on-disk
  types that exclude it; the strict factories now reject `attributes` at
  runtime.
- create_zarr_v2_consolidated_metadata_json validated nothing — a
  do-nothing factory of exactly the kind the module docstring rejects.
  It now checks the .zmetadata envelope (key presence, integer format
  marker, string-keyed mapping of JSON objects).

The module docstrings stop claiming the Unpack[TypedDict] signature is
"strictly stronger than any runtime completeness check": the static
guarantee is call-site-shaped — it holds for literal keywords, evaporates
under **-splat in both pyright and mypy, and unknown-key rejection varies
with PEP 728 support. The runtime pass is documented as existing for
exactly the callers the static story cannot see.

Assisted-by: ClaudeCode:claude-fable-5
The kind guards classified object forms by name alone, so
is_array_array_codec({"name": "transpose"}) narrowed a dict with no
configuration to TransposeCodecObject, whose configuration is required —
reading the narrowed value raised KeyError. TypeIs narrowing is
two-sided, so the fix must be exact: looser than the type lies in the
positive branch, stricter lies in the negative branch.

New private module zarr_metadata.v3._shape holds one type-level validator
per known codec and chunk grid, exact with respect to the declared
TypedDicts. Key sets are derived from __annotations__/__required_keys__
(never restated); only per-field value checks are written out. Two
package-wide conventions qualify exactness: int-annotated fields mean
JSON integers (booleans rejected, matching the fill-value rules), and
judgments are at the canonical data level (JSON arrays as tuples).

The guards now answer via the validators — is_known_codec({}) is False
instead of KeyError — and the hand-written bare-name sets are gone: the
bare form is permitted exactly when the object form's configuration is
NotRequired, derived per type. The semantic spelling rules delegate to
the same validators, which closes the config-value hole the adversarial
review found: {"name": "gzip", "configuration": {"level": "high"}},
endian "middle", blosc cname "nope", non-integer transpose orders, and
unexpected configuration keys are now rejected instead of sailing
through a key-presence-only check. One mechanism, two consumers — the
guards and the rules cannot drift from each other or from the types.

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

The reframed scope of this branch is: in addition to code that models
the structure of the literal JSON, ship code that models the rules
governing composition of the elements of a full metadata document. That
makes the rule set the product, not an implementation detail of the
builder — so it moves out of builder._rules into a public subpackage,
and the package's layering becomes one contract per layer:

1. model      — structure, element by element; never interprets
2. rules      — composition across the document; rules are data
3. builder    — construction conveniences applying layers 1 + 2

Two composition checks that had leaked into the structural validator
(v3 dimension_names vs shape, v2 chunks vs shape) move to the rules
layer, making the model layer's "structure only" claim true and fixing
the double-report where one dimension_names mismatch produced two
differently-worded problems. Model-layer parse_*/is_* and the model
dataclasses now accept those documents — they are lossless, structurally
well-formed representations of what a store may contain — and the rules
layer owns the judgment.

zarr_metadata.rules exports the engine (Rule, applicable, run_rules),
the rule sets (ZARR_V3_ARRAY_RULES, new ZARR_V2_ARRAY_RULES), and
read-side validate_/is_/parse_ trios that mirror the model grammar with
a stronger judgment: structure and composition, every problem reported
together. The is_* functions deliberately return bool rather than
TypeIs — only the structural layer can narrow honestly, since a
composition-invalid document is still an instance of the TypedDict.

The v2 create factories now run the v2 rules, so the moved chunks/shape
check holds at construction; builder's public surface drops the rule
names in favor of the new subpackage.

Assisted-by: ClaudeCode:claude-fable-5
The adversarial review demonstrated nine invalid documents the rule set
accepted; this makes the rule inventory enumerable from the spec and
closes them all. New rules, each with loc-precise problems:

- Regular chunk grids: chunk extents must be positive (accepting
  chunk_shape (0, 2) while rejecting fill_value 5.0 was strictness in
  the wrong order). Rectilinear grids gain their geometry: chunk_shapes
  rank must match shape, and each explicitly-listed dimension's chunk
  sizes (including [size, count] RLE pairs) must sum to that dimension's
  extent; bare-integer specs are uniform shorthand with no sum
  constraint. Positivity of sizes and counts is checked in both grids.
- Transpose: order must be a permutation of its own indices (any
  pipeline depth), and a top-level order must rank-match shape.
- Sharding: inner codecs and index_codecs are pipelines like any other —
  the same kind-ordering, known-shape, and transpose checks recurse
  through them at every nesting depth, so a violation caught at depth 0
  can no longer hide at depth 1. Inner chunk_shape must be positive,
  rank-match the enclosing chunk, and divide it evenly, recursively
  (each sharding level encloses the next).
- v3 groups get ZARR_V3_GROUP_RULES: inline consolidated metadata
  recurses, judging every embedded array document by the array rules
  and every embedded group by the group rules, reporting at the child's
  path. The group create factory and new group read-side trios apply it.

Shape-invalid entries decline in favor of the spelling rule (no noise on
top of its report), and unknown names stay unjudged (extension
openness). The read-side validate_* functions now normalize JSON arrays
to tuples before judgment so list-spelled json.loads output is judged at
the canonical data level rather than rejected for its spelling.

New drift tests tie the judgment registries to the type modules: a new
codec/chunk-grid/data-type module that is not kind-classified,
shape-registered, or fill-value-judged fails a test instead of silently
weakening validation (an unregistered codec would suppress the
exactly-one-array->bytes check for every pipeline containing it).

Assisted-by: ClaudeCode:claude-fable-5
Add API pages for zarr_metadata.rules and zarr_metadata.builder (both
previously undocumented), wire them into the nav and the API index, and
bring the changelog fragments up to date with the reframed design: the
codec-kind fragment now describes shape-exact guards, the
document-factories fragment stops claiming call-site static enforcement
survives **-splat and records the strict-shape runtime backstops, and a
new rules-layer fragment covers the promotion, the rule inventory, the
read-side trios, the moved layer boundary, and the package's strictness
stance.

Assisted-by: ClaudeCode:claude-fable-5
from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON


def validate_array_metadata_v3(value: object) -> list[ValidationProblem]:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer tuples over lists for this return type

"""
normalized = arrays_to_tuples(value)
problems = validate_array_metadata_v3(normalized)
if problems:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avoid falsy iterables. check explicitly if the length is 0

return cast("ZarrV2ArrayMetadataJSON", normalized)


def validate_group_metadata_v3(value: object) -> list[ValidationProblem]:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider making this return type a tagged union wrapping either the collection of validation problems or the validated document



@dataclass(frozen=True, slots=True)
class Rule:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is cool but I don't want to be creative here. is there prior art for this design that we can cite / lean on? we don't want a formal dependency, but we do want to avoid making mistakes that have been sanded off by experience in other projects.

# ---------------------------------------------------------------------------


def _valid_grid_configuration(grid: object) -> tuple[str, Mapping[str, object]] | None:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this design has _v3_array.py owning a rule that depends on the details of two chunk grids. what happens if we add a third? a better design gives individual fields / variants of fields their own rules, which are collected / called in a central place. then adding a new codec / chunk grid etc doesn't require changes in this module.

return _sharding_geometry_problems(entries, chunk_shape, ("codecs",))


ZARR_V3_ARRAY_RULES: Final[tuple[Rule, ...]] = (

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

manually constructing this variable here means we can easily define a rule and forget to register it. consider more robust alternatives.



class RectilinearChunkGridConfiguration(TypedDict):
class RectilinearChunkGridConfiguration(TypedDict, closed=True):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is making these fields closed supported by the spec? if it's ambiguous in some cases (which I suspect it might be, e.g. for codecs) we should ensure that this library can still represent at some level metadata that uses the implicit openness, even if the core validation assumes closed.

validate_array_metadata_v3,
)

V3_ARRAY: dict[str, Any] = {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this typed dict[str, Any]? The bar for using Any should be incredibly high in this codebase. object is preferred in 99% of cases. And we know it's a metadata document. We have a type for that.

d-v-b added 5 commits August 14, 2026 12:25
PR review: prefer tuples over lists for these return types, and avoid
falsy-iterable checks.

Every validate_* across model and rules now returns
tuple[ValidationProblem, ...], and MetadataValidationError.problems is a
tuple. A validation report is a finished record — handing back a mutable
list invited callers to edit it, and immutability is already the
package's default for JSON-array-shaped data. The error constructor
still accepts any Sequence, so callers building a report incrementally
keep cheap appends and hand the list over at the boundary.

Emptiness is tested explicitly with len(...) == 0 rather than by
truthiness. That change also surfaced a real hazard it is designed to
prevent: validate_known_codec_metadata and
validate_known_chunk_grid_metadata return tuple | None, where None means
"not a known entity, unjudged" and () means "known and valid". Truthiness
conflated those two; the call sites now test `is None` explicitly and say
so in a comment.

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

PR review: the bar for `Any` should be incredibly high here; `object` is
preferred, and we have types for metadata documents.

Test fixtures that model real documents are now typed as the document
TypedDicts (ZarrV3ArrayMetadataJSON, ZarrV2ArrayMetadataJSON); fixtures
that are deliberately malformed — the whole point of an error test — are
`Mapping[str, object]` / `dict[str, object]`, which is honest about what
they are and still type-checks at the call sites, since every validator
takes `object`. Parametrized columns holding the trios use precise
Callable aliases instead of `Callable[..., Any]`. The package's test
suite now contains zero occurrences of `Any`.

To keep it that way, ruff's ANN401 is enabled for the package, so a new
`Any` annotation fails lint rather than relying on review to catch it.
That surfaced one in the source too: `load_store_json` returned `Any`,
which several callers were already wrapping in `cast("object", ...)` —
laundering that the signature invited. It returns `object` now and those
casts are gone: what a store holds is unknown until a validator says
otherwise.

Note for reviewers: pyright covers `src` only, so test annotations are
not machine-checked today. Putting `tests` under strict pyright surfaces
~150 pre-existing findings and is worth its own pass.

Assisted-by: ClaudeCode:claude-fable-5
PR review asked whether `closed=True` on the entity TypedDicts is
supported by the spec, and whether the library can still represent
metadata that uses implicit openness.

Answer, from the spec text: it is ambiguous, not settled. The core spec
constrains an extension's `configuration` only to "be an object" and
says nothing about its members, and `must_understand` is defined over
metadata *document fields*, so it structurally cannot reach inside a
configuration. The question has been open since 2023 (zarr-specs#270),
filed after a real interop break: jzarr emitted
blosc.configuration.numThreads and zarr-python refused the array.

So closed=True stays — it matches 44+/48 registered extension schemas,
the proposed core JSON schema, and the zarrs, tensorstore and zarr-java
implementations, and it is load-bearing for the two-sided TypeIs
narrowing. What changes is the blast radius of being wrong:

- Unknown members report as a new `unknown_key` ProblemKind rather than
  `invalid_value`, so callers can dispatch a tolerance policy instead of
  string-matching, and the message can say "unrecognized" not "invalid".
- Rules that read a known entity's fields now ignore `unknown_key` when
  deciding whether the entity is interpretable. Previously one cosmetic
  extra key made the whole entity opaque and silently suppressed every
  other rule about it — a stray "hint" hid a genuine chunk-geometry
  error. Both are now reported.
- A regression test pins byte-faithful round-tripping of unmodelled
  members, using the jzarr numThreads case as the fixture. zarr-python's
  own chunk-grid path is lossy here; this asserts we are not.

The TypeIs guards keep the exact check: narrowing is two-sided, and a
value carrying an extra member is not an instance of a closed TypedDict.

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

Three PR comments, one answer: rules are now registered where they are
defined, entity-specific rules live with their entity, and the engine
cites its prior art instead of presenting the design as invention.

"Manually constructing this variable means we can easily define a rule
and forget to register it." Registration is now the definition:
@document_rule / @entity_rule add the rule to the registry as a side
effect, and the rule sets are assembled from it. Both decorators also
check the declared `requires` against the document type's known keys at
import time, and @entity_rule requires the entity to have a shape
validator — a rule that could never fire now raises immediately instead
of passing silently forever. This caught a real case while landing:
`consolidated_metadata` is not a declared member of the group TypedDict
(the spec grandfathers it), so it must be declared as a known extension
key rather than assumed.

"This design has _v3_array.py owning a rule that depends on the details
of two chunk grids. What happens if we add a third?" Entity rules now
live one module per codec or chunk grid under rules/_entities,
auto-discovered on import and dispatched generically by name. Adding a
grid means adding its types, its shape validator, and its rules module —
and touching neither the array-rules module nor the registry. A drift
test asserts every modelled entity is either registered or listed as
deliberately rule-free, so the choice is explicit either way.

"Is there prior art we can lean on?" Yes, and the gate is the
conventional part: Ecto's Changeset.validate_change/3 runs a validator
"only if a change for the given field exists", which is how one changeset
serves both full inserts and partial updates; Clojure spec's two-phase
s/keys exists because "we routinely deal with optional and partial
data"; Valibot's partialCheck takes the paths a cross-field rule reads
and runs it "whenever the selected part of the data is valid". The
docstring cites these and records two consequences worth knowing: the
gate is order-free (no topological sort, so unlike Yup cyclic
dependencies are expressible), and absence is deliberately inexpressible,
being negation-as-failure over an open world — required-key checks stay
in the structural layer, the same stratification Ecto, spec, and JSON
Schema all apply.

Also from that survey: `inapplicable()` joins `applicable()`, because a
rule that did not run is otherwise indistinguishable from one that
passed. Deequ annotates wanting exactly this in-source, and Soda's
NOT EVALUATED state is the model.

Rule.keys is renamed Rule.requires. Sharding's recursion now judges
inner pipelines against a synthetic document describing the inner chunk,
so a transpose inside a shard is checked against the inner chunk's rank —
which it previously escaped entirely.

Assisted-by: ClaudeCode:claude-fable-5
PR review: "consider making this return type a tagged union wrapping
either the collection of validation problems or the validated document."

Added alongside the trios rather than replacing them. validate_* returns
a problem collection whose emptiness a type checker cannot connect to the
document's validity, and parse_* moves the failure into control flow;
neither lets a caller hold both outcomes in one value with the checker
enforcing that they looked. check_* returns Valid[T] | Invalid, whose
arms differ in a Literal[bool] discriminant, so narrowing on
`result.valid` yields `result.document` in one branch and
`result.problems` in the other — reading the wrong one is a static error.

The trios stay because they mirror the model layer's grammar
name-for-name, and someone who knows model.validate_array_metadata_v3
should not have to learn a second shape to get the composition-aware
judgment. The module docstring says which to reach for when.

Note this supersedes the earlier rejection recorded in zng#21, which
turned down an Ok/Err *replacement* for the raising API; this is
additive, and the discriminant carries its guarantee statically rather
than by convention.

Assisted-by: ClaudeCode:claude-fable-5
"create_zarr_v2_array_metadata_json",
"create_zarr_v2_consolidated_metadata_json",
"create_zarr_v2_group_metadata_json",
"create_zarr_v2_z_array_json",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer create_zarr_v2_zarray_json and create_zarr_v2_zgroup_json. ensure that no other instances of "z_array" and "z_group" exist.


# -- evolution ----------------------------------------------------------

def evolve(self, **kwargs: Unpack[ZarrV3ArrayMetadataJSONPartial]) -> Self:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this name is vague, and especially confusing in conjunction with without, which implies a with. we should find something expressive and symmetrical, like with_fields and without_fields

d-v-b added 6 commits August 14, 2026 13:44
…onicalization

Core and extension identifiers are now a separate piece of data keyed by
metadata field, as discussed. Keying by field rather than by name is
forced by a real collision: `bytes` is a core codec and, separately, a
registered extension data type. Names in Zarr v3 are unique only within
an extension point, which is also how the zarr-extensions registry is
laid out — its directories are the points.

Provenance turns out to need three states rather than two. The zstd
codec's cited specification is an open pull request, so anything typed
against BytesBytesCodecMetadata today is partly typed against a draft;
Provenance.PROPOSED says so rather than filing it under either core or
registered.

Name canonicalization makes the parameterized raw-bytes family tractable
as a table key: r8/r16/r24 all reduce to "r<N>", the spec's own notation
for the family. Two properties are deliberate. It is field-aware, so a
codec that happens to be named r8 is not canonicalized by a data-type
rule. And it reduces by grammar shape rather than validity — r12 and r0
canonicalize into the family too, because a malformed member of a family
we model is a misspelling to report, and gating on validity would let it
pass as an unknown third-party extension instead, the same masquerade
this package already refuses for codec names. Canonical names are lookup
keys only: never emitted, never shown to a user, since "r<N> is not a
valid data type" is a worse error than naming the actual input.

Two things fall out. The ^r(\d+)$ grammar now has one owner instead of
being duplicated between the raw data type module and the rules layer.
And entity rules are re-keyed by (field, name), closing a latent bug in
the registry landed a few commits ago: numpy.datetime64, numpy.timedelta64
and struct all carry configurations and are the obvious next entity-rule
candidates, so a rule for the `bytes` data type would have fired on the
`bytes` codec.

The table also absorbs per-point policy that was scattered: which points
forbid must_understand: false, and which hold a sequence rather than a
single entity. storage_transformers is listed with no identifiers, so its
emptiness is a stated fact rather than an oversight.

Provenance is irreducible knowledge — nothing in the type modules records
where a name was standardized — so the table is hand-written and drift
tests tie it to the names those modules define.

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

Zarr identifiers are registry-allocated, so a document using `bytes` for
its own private codec is not a different-but-valid document — it has
left the compatibility contract, and no validation library can help it.
The extension-point table models the registry as authoritative and
defends nothing against collisions; a squatted name is judged against
the definition it squats, which is the correct answer rather than a
limitation.

Recorded because it is otherwise an unexamined assumption that invites a
later "fix". Two tests pin the behavior so the invariant is executable:
a private codec named `bytes` is reported against the core `bytes`
shape, and a document whose data type is the literal string `r<N>`
mislabels its provenance and nothing else — the rules layer matches the
raw-bytes family through RAW_BYTES_NAME_PATTERN rather than through the
table key, so no validation verdict depends on the sentinel being
unforgeable. That is a smaller blast radius than the earlier note in
this PR claimed.

Assisted-by: ClaudeCode:claude-fable-5
Assisted-by: Codex:gpt-5
…work

Two lenses (roborev and an independent reviewer) plus my own probing
found two High issues in the "validate every modelled extension point"
change. Both were silent-failure classes, which is what makes them worth
a careful fix rather than a patch.

**Entity rules at data_type and chunk_key_encoding could never fire.**
Shape validators were registered for four extension points but
dispatchers for only two, so entity_rule accepted a registration at the
other two and the rule then never ran — precisely the failure the
registry exists to prevent, and a regression of the guard added a few
commits ago. All four points now dispatch, and two new tests assert that
every shape-modelled field and every field with rules has a dispatcher,
so it cannot reopen. The drift test had been papering over it: 22
data_type/chunk_key_encoding entries were added to _RULE_FREE, keeping it
green while the invariant it guards was false for half the table.

The dispatch fix immediately unblocks real work: `struct` gains the first
rules that were previously impossible to write — field names must be
non-empty and unique, both promised by StructField's docstring and
neither expressible in a TypedDict.

**Object-form entity metadata was rejected.** {"name": "uint8"} and
{"name": "string"} were invalid while the bare names were valid, so
zarr_metadata.model and zarr_metadata.rules disagreed about the same
document — model types data_type as `str | ZarrV3NamedConfigJSON` and
accepts both. The spec makes the object form the base spelling and the
bare name optional short-hand when no configuration is required, so
rejecting it was backwards; `string` and `bytes` are registered
extensions, where that reading is clearest. `object_permitted` is gone
rather than flipped: it existed only to suppress the base form. A
configuration member on an entity that takes none is still unknown_key.

Also fixed:

- Nested data_type positions (cast_value's target type, struct field
  types) are judged by the same shapes a top-level data_type gets,
  recursively. A bare "numpy.datetime64" was invalid at the top level and
  silently fine one level down.
- examples/ ships in the sdist. tests/test_examples.py executes it, so
  the allowlist omission made an sdist test run fail with
  FileNotFoundError; verified against a real built sdist.
- Changelog fragments renamed from +placeholder to the PR number, which
  is what "Check changelog entries" was failing on.
- zarr_consolidated_format is typed Literal[1], matching the validator
  that already rejected anything else instead of contradicting it.
- The engine docstring's prior-art citations are restored (Ecto, Clojure
  spec, Valibot, JSON Schema), along with the order-free and
  negation-as-failure consequences. They were requested content, not
  verbosity.
- _shape's "derived from the TypedDicts, cannot drift" claim is corrected:
  it is false for the core scalar data types, which have no TypedDict to
  derive from and are hand-listed.
- _known_entity_shape took two ExtensionPointField parameters always
  passed the same value — the wrong-field hazard, for no benefit.
- Two generative tests computed both sides of their assertion from
  _ENTITY_SHAPES through the same call, so they restated the lookup and
  could not fail; cut. Canonicalization keeps generative coverage, where
  the unbounded family makes it earn its keep.
- docs link to examples/ pointed at a main blob URL for a file that only
  exists on this branch.

Assisted-by: ClaudeCode:claude-fable-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant