Skip to content

fix(cast_value): reject fill values that do not survive a round trip - #4510

Draft
d-v-b wants to merge 8 commits into
zarr-developers:mainfrom
d-v-b:fix/cast-value-fill-roundtrip
Draft

d-v-b wants to merge 8 commits into
zarr-developers:mainfrom
d-v-b:fix/cast-value-fill-roundtrip

Conversation

@d-v-b

@d-v-b d-v-b commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

The cast_value codec now checks that the array's fill value survives a round trip through the codec, as the cast_value spec requires. Previously out_of_range="clamp" or "wrap", or rounding, could encode the fill value to something that decodes to a different value, so the unwritten part of a partly written chunk read back as that value instead of the fill value. For example, a float32 array with fill value 1000.0 and an int8 target with clamping read 127.0 there. Creating such an array now raises a ValueError that names the fill value, both data types, and the value it decodes to. Opening an array already stored with such a fill value still works: it keeps its fill value, and a warning says what its partly written chunks hold and how to fix it. The check runs once when metadata is created or read, not for every chunk, and the codecs inside a shard are checked with the array's fill value rather than the data type's default scalar, so a shard's codec chain is no longer refused for a fill value it would never be asked to encode.

Spec

From the data_type section of the cast_value spec:

The fill value of the output array MUST be cast to the target data type using the same casting semantics as elements. … Implementations SHOULD validate at metadata construction time that the fill value can be successfully cast in both the forward (encode) and inverse (decode) directions using the codec's configuration. If the fill value cannot survive a round-trip cast, implementations MUST treat this as an error.

CastValue.resolve_metadata cast the fill value forward only, with the encode scalar_map, and never checked the inverse.

Reproduction (before this change, cast-value-rs 0.4.2)

import zarr
from zarr.storage import MemoryStore
a = zarr.create_array(MemoryStore(), shape=(4,), chunks=(2,), dtype="float32", fill_value=1000.0,
                      filters=[{"name": "cast_value", "configuration": {"data_type": "int8", "out_of_range": "clamp"}}],
                      compressors=None)
a[0] = 5.0
a[:]  # array([   5.,  127., 1000., 1000.], dtype=float32)

Element 1 sits in a partly written chunk, so it is stored as the encoded fill (127) and decodes to 127.0. Elements 2 and 3 are in a chunk that was never written, so they read the fill directly. After this change, create_array raises:

ValueError: The fill value 1000.0 does not survive a round trip through the cast_value codec: it encodes from float32 to 127 in int8, which decodes back to 127.0 in float32. Choose a fill value that the codec's data_type, rounding, out_of_range, and scalar_map preserve.

An array already stored that way opens with a ZarrUserWarning that carries the same text, followed by what it means for the stored chunks and how to fix the array, and reads exactly as before.

Where the check runs

The check is not in resolve_metadata, which the chunk pipeline calls for every chunk read or written and which ShardingCodec calls with the data type's default scalar when it validates its inner codecs. Instead:

  • CastValue._check_fill_value(spec) does the round trip for one codec and raises.
  • _check_fill_value_round_trip(metadata) threads the array's fill value through the codecs, as a chunk is encoded, and calls _check_fill_value on every cast_value codec with the fill value it would receive, descending into the codecs of a sharding codec.
  • AsyncArray._create_metadata_v3, which every array creation goes through, calls it and lets the error propagate.
  • ArrayV3Metadata.from_dict calls it after constructing the metadata and turns the error into a Reading warning through the existing metadata repair machinery, so an existing array stays readable.

resolve_metadata keeps casting the fill value forward only, as it did before, so the per-chunk cost is unchanged. It now wraps a failed forward cast in a ValueError that names the fill value and both data types, which _check_fill_value reuses.

Tests

In tests/test_codecs/test_cast_value.py:

Test Case Result
test_fill_value_roundtrip_accepted[...] (7 cases) NaN through a scalar map, in-range clamp and wrap, int to float, integral float to int, exact float narrowing, signed zero float to float accepted; the encoded fill is stored and reads back bit for bit
test_fill_value_partial_chunk_without_check_reads_wrong_value float32 1000.0 to int8, clamp with the check patched out, the unwritten element reads 127.0; creation raises; the stored array opens with a warning and reads the same values
test_fill_value_checked_inside_shards shards with ScaleOffset(offset=100) then uint16 clamp, fill 100.0; shards with int8 clamp, fill 1000.0 the first is accepted although the default scalar 0.0 would be lost; the second raises
test_resolve_metadata_only_casts_forward float32 1000.0 to int8, clamp resolve_metadata returns 127 without raising
test_fill_value_clamp_out_of_range_rejected float32 1000.0 to int8, clamp raises, comes back 127.0
test_fill_value_wrap_out_of_range_rejected int16 300 to int8, wrap raises, comes back 44
test_fill_value_lost_to_rounding_rejected float64 1.5 to int8, nearest-even raises, comes back 2.0
test_fill_value_signed_zero_lost_rejected float32 -0.0 to int8 raises, comes back 0.0
test_fill_value_nan_to_int_without_scalar_map_rejected float32 NaN to int8, no map raises, forward cast fails
test_fill_value_cannot_decode_rejected int32 0 to float64, encode 0 to NaN, no decode map raises, inverse cast fails

Results with uv sync --extra cast-value-rs --extra optional --group test (cast-value-rs 0.4.2):

  • tests/ (full suite, -n auto, without tests/test_regression): 12520 passed, 1276 skipped, 4 xfailed
  • prek run on the changed files: ruff check, ruff format, codespell, numpydoc validation and the rest pass; the mypy hook reports only the pre-existing type: ignore[no-any-return] in _do_cast as unused, because this environment has the cast-value-rs extra installed and the package ships type stubs, which the lockfile environment CI uses does not have
Bookkeeping

🤖 Generated with Claude Code

d-v-b and others added 3 commits October 14, 2026 11:22
The cast_value spec requires the fill value to survive a forward and
inverse cast with the codec's configuration, and requires an error when
it does not. resolve_metadata cast the fill value forward only, so
out_of_range "clamp" or "wrap", or rounding, accepted a fill value that
decodes to something else. A partly written chunk then read that value
where it should read the fill value: float32 fill 1000.0 with an int8
clamp target read back as 127.0.

resolve_metadata, which ArrayV3Metadata runs at construction, now casts
the encoded fill back with the decode scalar_map and compares it with
the original. NaN matches NaN; the sign of zero must match. A fill value
whose forward or inverse cast fails gets an error that names it as the
fill value.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.72%. Comparing base (bd0dc84) to head (cd5fa7f).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4510      +/-   ##
==========================================
+ Coverage   94.71%   94.72%   +0.01%     
==========================================
  Files          94       94              
  Lines       13663    13700      +37     
==========================================
+ Hits        12941    12978      +37     
  Misses        722      722              
Files with missing lines Coverage Δ
src/zarr/codecs/cast_value.py 98.93% <100.00%> (+0.19%) ⬆️
src/zarr/core/array.py 98.44% <100.00%> (+<0.01%) ⬆️
src/zarr/core/metadata/v3.py 96.95% <100.00%> (+0.04%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

d-v-b added 5 commits October 21, 2026 16:47
Move the round-trip check out of `CastValue.resolve_metadata`, which runs
for every chunk and which `ShardingCodec.validate` probes with the data
type's default scalar, into `check_fill_value_round_trip`, which threads
the array's fill value through the codecs, descending into shards. Array
creation calls it and raises; `ArrayV3Metadata.from_dict` calls it and
warns, so an array already stored with such a fill value still opens and
reads as before.

Assisted-by: ClaudeCode:claude-fable-5-1
`resolve_metadata` and `check_fill_value` each cast a one-element fill
array and wrapped the backend's error with the fill value and both data
types. `_cast_fill` does that in either direction for both of them.

Assisted-by: ClaudeCode:claude-fable-5-1
…array opens

Assisted-by: ClaudeCode:claude-fable-5-1
`_check_fill_value` and `_check_fill_value_round_trip` are called only by
array creation and metadata reading, so they are not public API.

Assisted-by: ClaudeCode:claude-fable-5-1

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant