feat(zarr-metadata): composition rules layer, shape-exact codec guards, typed builders - #296
feat(zarr-metadata): composition rules layer, shape-exact codec guards, typed builders#296d-v-b wants to merge 42 commits into
Conversation
…#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
…typeddict-builder-cd5f75
…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
…typeddict-builder-cd5f75
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]: |
There was a problem hiding this comment.
prefer tuples over lists for this return type
| """ | ||
| normalized = arrays_to_tuples(value) | ||
| problems = validate_array_metadata_v3(normalized) | ||
| if problems: |
There was a problem hiding this comment.
avoid falsy iterables. check explicitly if the length is 0
| return cast("ZarrV2ArrayMetadataJSON", normalized) | ||
|
|
||
|
|
||
| def validate_group_metadata_v3(value: object) -> list[ValidationProblem]: |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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, ...]] = ( |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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] = { |
There was a problem hiding this comment.
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.
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", |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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
…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
Assisted-by: Codex:gpt-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
🤖 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:
zarr_metadata.model— structure, element by element; never interprets extension pointszarr_metadata.rules(new) — composition across the document; rules are data, keyed by the keys they readzarr_metadata.builder(new) — construction conveniences that apply layers 1 + 2The composition layer
zarr_metadata.rulesexports the engine (Rule,applicable,run_rules), per-document rule sets, and read-sidevalidate_*/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 loadedzarr.jsona document I should act on?" — which previously had no blessed path. Theis_*functions deliberately returnbool, notTypeIs: 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_namescounts, 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 withshape), and sharding — innercodecs/index_codecsare judged as pipelines recursively at every nesting depth, and inner chunk shapes must be positive, rank-matched, and evenly divide the enclosing chunk, recursively. NewZARR_V2_ARRAY_RULES(chunks/shape rank) andZARR_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_names↔shape, v2chunks↔shape) moved into the rules layer.modelparsers 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 onedimension_namesmismatch was reported twice in different words.Strictness stance, now documented on the package:
zarr_metadatamodels canonical documents and is deliberately stricter than any given implementation (it rejectsfill_value: 5.0foruint8; 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.kindships branded per-kind unions andTypeIsguards. An adversarial review found the object-form guards unsound —is_array_array_codec({"name": "transpose"})narrowed a dict with noconfigurationtoTransposeCodecObject(reading the narrowed value raisedKeyError), andis_known_codec({})itself raised.TypeIsnarrowing is two-sided, so the fix is exactness: a newv3._shapemodule 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", blosccname: "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/.zgroupfactories rejectattributesat runtime (previously a splattedattributescame back typed as a shape that forbids it), and the v2 consolidated factory validates the.zmetadataenvelope 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 | UNSETproperties on the PEP 661 sentinel, no shared mutable state, nobuild_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
TypeIsunsoundness, the drifted"regular"literal, malformedr<N>dtype names escaping judgment, the.zarray/.zgrouptype-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 checkgreen throughout — ruff, pyright 1.1.404 strict (0 errors), 734 tests, strict docs build (new API pages forrulesandbuilder).Known follow-up: v2 fill-value/dtype consistency (NumPy dtype grammar) has no rule yet; front-door re-export of
rulesnames is an open curation decision.🤖 Generated with Claude Code