Skip to content

fix(ArraySpec): proper and robust equality semantics for ArraySpec by checking fill_value for byte-identicality, fixes #3054.#4183

Open
sehoffmann wants to merge 2 commits into
zarr-developers:mainfrom
sehoffmann:fix/3054
Open

fix(ArraySpec): proper and robust equality semantics for ArraySpec by checking fill_value for byte-identicality, fixes #3054.#4183
sehoffmann wants to merge 2 commits into
zarr-developers:mainfrom
sehoffmann:fix/3054

Conversation

@sehoffmann

Copy link
Copy Markdown

Summary

ArraySpec describes the exact layout for an array. The previously auto-generated __eq__ and __hash__ methods from @dataclass did not reflect this correctly due to the fill_value attribute.

Specifically:

  • For IEEE float nans: nan != nan and thus ArraySpec(**kwargs) != ArraySpec(**kwargs) if the fill value is nan (although it they are in fact identical as they have the same fill).
  • Same issue if numpy's np.datetime64('NaT') which is also not self-equal
  • On the other hand, float(-0.0) == float(0.0) despite these two fill_value's being different float numbers. They would result in different arrays in memory and on disk.
  • Most importantly, if fill_value is a np.void, such as from a structured dtype, hash(ArraySpec(...)) failed outright, because (mutable) np.voids are not hashable. (Issue sharding codec use of lru caching fails with numpy void scalars #3054).

This PR fixes all of the above by comparing fill_value using byte-identity, which should be the correct semantics for a fill value. It also adds a comprehensive test suite for the equality and hash semantics of ArraySpec which was non-existent before.

Before this fix/PR, the following (newly added) tests failed:

FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_hashable[structured-void]** - TypeError: unhashable type: 'writeable void-scalar'
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_equal_specs_hash_equal[float64-nan]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=Float64(endianness='little'), fill_value=np.float64(nan), config=ArrayConfig(order='C', ...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_equal_specs_hash_equal[datetime64-NaT]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=DateTime64(endianness='little', scale_factor=1, unit='s'), fill_value=np.datetime64('NaT...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_equal_specs_hash_equal[structured-void]** - TypeError: unhashable type: 'writeable void-scalar'
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_distinct_specs_unequal[shape-structured-void]** - TypeError: unhashable type: 'writeable void-scalar'
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_distinct_specs_unequal[order-structured-void]** - TypeError: unhashable type: 'writeable void-scalar'
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_distinct_specs_unequal[prototype-structured-void]** - TypeError: unhashable type: 'writeable void-scalar'
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_signed_zero_fills_are_distinct[float16]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=Float16(endianness='little'), fill_value=np.float16(-0.0), config=ArrayConfig(order='C',...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_signed_zero_fills_are_distinct[float32]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=Float32(endianness='little'), fill_value=np.float32(-0.0), config=ArrayConfig(order='C',...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_signed_zero_fills_are_distinct[float64]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=Float64(endianness='little'), fill_value=np.float64(-0.0), config=ArrayConfig(order='C',...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_signed_zero_fills_are_distinct[complex128-both]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=Complex128(endianness='little'), fill_value=np.complex128(-0-0j), config=ArrayConfig(ord...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_signed_zero_fills_are_distinct[complex128-imag]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=Complex128(endianness='little'), fill_value=np.complex128(-0j), config=ArrayConfig(order...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_signed_zero_fills_are_distinct[complex128-real]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=Complex128(endianness='little'), fill_value=np.complex128(-0+0j), config=ArrayConfig(ord...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...
FAILED tests/test_array_spec.py::**TestArraySpecHashEq::test_signed_zero_fills_are_distinct[structured]** - AssertionError: assert ArraySpec(shape=(4, 4), dtype=Struct(fields=(('a', Float64(endianness='little')),)), fill_value=np.void((-0.0,), dtype...otype=BufferPrototype(buffer=<class 'zarr.core.buffer.cpu.B...

With the fix applied, all tests pass. Also, before this fix, the following valid zarr code would throw an exception:

t = np.dtype([("a", "i4"), ("b", "f4")])

arr = zarr.create_array(
    MemoryStore(),
    shape=(8,),
    chunks=(2,),
    shards=(4,),
    dtype=dt,
    fill_value=(0, 0.0),
)
arr[:] = np.ones(8, dtype=dt)  # -> TypeError: unhashable type: 'writeable void-scalar'

This PR fixes the above.

Behavioral Changes (summarized):

  • Structured / np.void fills: hash(spec) no longer raises TypeError; such specs are now hashable and usable as dict/set/cache keys. (Fixes sharding codec use of lru caching fails with numpy void scalars #3054.)
  • nan fills: two specs with a NaN fill now compare equal and hash equal; before they compared unequal (nan != nan).
  • NaT fills: same change as NaN: now equal, were unequal.
  • Signed zero (+0.0 vs -0.0): now compare unequal (distinct fills, distinct bytes); before they compared equal.
  • Comparison basis: numpy-scalar fills are now compared by exact bit pattern rather than numeric ==, so equality no longer inherits numpy's value-promotion quirks. (e.g. see BUG: numpy int64/float64 comparisons are inexact numpy/numpy#32071)

For reviewers

#3054 discusses getting rid of the lru cache entirely. This is an orthogonal design decision in my opinion. While this PR fixes the exception, it does not remove the lru cache. Instead, it ensures that ArraySpec has robust hash semantics in the first place which is a good thing regardless of #3054 and whether an lru cache is used or not.

Author attestation

  • I am a human, these are my changes, and I have reviewed and understood every change and can explain why each is correct.

TODO

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/user-guide/*.md
  • Changes documented as a new file in changes/
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

@github-actions github-actions Bot added the needs release notes Automatically applied to PRs which haven't added release notes label Jul 23, 2026
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.85%. Comparing base (80e00ae) to head (55f096e).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4183   +/-   ##
=======================================
  Coverage   93.84%   93.85%           
=======================================
  Files          91       91           
  Lines       12549    12561   +12     
=======================================
+ Hits        11777    11789   +12     
  Misses        772      772           
Files with missing lines Coverage Δ
src/zarr/core/array_spec.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sehoffmann

Copy link
Copy Markdown
Author

Should I add a test case that non-ArraySpec comparison raises as requested by the codecov plugin? IMO this is a trivial case..

@d-v-b

d-v-b commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Thanks, this is a great fix. I will leave a few comments, but IMO this is good to go

Should I add a test case that non-ArraySpec comparison raises as requested by the codecov plugin? IMO this is a trivial case..

only if you feel like it.

Comment thread tests/test_array_spec.py
]


class TestArraySpecHashEq:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i don't know why we need a class here. IMO plain functions and data are simpler

Comment thread tests/test_array_spec.py
]


# Mutations: each mutate kwargs to an uneqal version

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
# Mutations: each mutate kwargs to an uneqal version
# Mutations: each mutate kwargs to an unequal version

Comment thread tests/test_array_spec.py
base = _make_spec(**kwargs)
variant = _make_spec(**{**kwargs, **mutate(kwargs)})
assert base != variant
assert hash(base) != hash(variant)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hashes collisions between unequal objects can occur, right? so maybe we just need to check that they are unequal, and not that the hashes differ?

@d-v-b

d-v-b commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

can you update the stale comment in sharding.py or re-enable the LRU cache?

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

Labels

needs release notes Automatically applied to PRs which haven't added release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharding codec use of lru caching fails with numpy void scalars

2 participants