Skip to content

feat(indexing): make LazyArray indexing lazy by default - #4350

Merged
d-v-b merged 4 commits into
zarr-developers:mainfrom
d-v-b:codex/indexing-lazy-default
Sep 16, 2026
Merged

d-v-b merged 4 commits into
zarr-developers:mainfrom
d-v-b:codex/indexing-lazy-default

Conversation

@d-v-b

@d-v-b d-v-b commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

This PR applies a range of improvements to the lazy indexing API, bringing it into closer alignment with tensorstore, although we still use relative indexing when indexing with plain python objects.

🤖 AI text below 🤖

LazyArray currently requires .lazy at each selection step; ordinary indexing materializes immediately. This PR makes the wrapper lazy by default, matching TensorStore: view[...], view.oindex[...], and view.vindex[...] return composed views, and iteration yields lazy first-axis views. result() and NumPy conversion materialize values. The .lazy accessor is removed.

Writes

Adds synchronous view.write(values) and assignment through composed views (view[...] = , view.oindex[...] = , view.vindex[...] = ). Writes update the original source, snapshot the RHS before mutation, and apply repeated destinations in C order with the last value winning, the same contract TensorStore uses. Out-of-range coordinates raise before anything is written.

Writes are planned against the source's write grid, discovered from write_chunk_sizes and then chunks, so sharded arrays are written by shard:

  • an independent affine selection is one basic slice assignment;
  • a NumPy source receives one fancy assignment;
  • any other readable source is written one grid cell at a time: the cell's touched hull is read once, updated in memory, and written back with one basic slice;
  • a source with no advertised grid, a source that cannot be read, or a transform the planner cannot factor falls back to one integer assignment per element, which never reads.

Storage round trips therefore scale with touched chunks rather than selected elements. On a 1000×1000 int64 zarr array with 100×100 chunks:

write native zarr per-element fallback this PR
vindex, 2,000 points 100 chunk writes 1,999 100
oindex, 50 rows 100 chunk writes, 10 ms 49,999, 27 s 100, 82 ms
basic strided slab 60 chunk writes 60 60

Writes bypass the reader, so a caching reader is not invalidated; this is documented and pinned by a test. Writes are not transactional, and backend errors can leave partial writes. No asynchronous execution engine is introduced.

Literal domains, relative keys

Views keep their literal domain instead of re-zeroing after every selection, as TensorStore views do: a[10:20] has domain [10, 20), a[10:20][2:5] has [12, 15), and a reversed view carries the negative origin the algebra already produced. NumPy keys stay positional, normalized against the domain's origin, so a[10:20][0] is still the first element.

The type of a key decides whether it is relative or absolute. NumPy keys (slices, integers, ..., None, index arrays, masks) are positions relative to the view. An IndexDomain key names absolute coordinates of the view's domain, restricted to a box; empty intervals outside the view are refused too. An IndexTransform key composes onto the view, so any selection can be spelled in absolute coordinates by indexing the identity over the view's domain. No key type has two readings, and whichever key produced a view, view.transform.domain is absolute. This is documented in a new guide section with a runnable snippet, in the module docstring, and in the README.

Box partitions keep the request's coordinates, so a part view's domain is a sub-domain of its parent's; a part placed by index arrays keeps a fresh zero-origin domain, and out_selection is the placement in both cases. Two frames stay zero-origin by construction so no consumer subtracts an origin: ReadContext re-bases its transform, and parts() re-bases each projection's cell_transform to positions in the view's result buffer. A table-driven test states the expected literal domain for every indexing form and nested chain.

Eager adapter

A separate EagerArrayAdapter supplies eager indexing for consumers such as Dask's from_array. It delegates shape, rank, dtype, NumPy conversion, and tokenization to the view and materializes each indexed block. Documentation, examples, and existing tests use the revised interface. The unreleased LazyArray changelog fragment now describes the shipped surface.

This changes the experimental zarr-indexing wrapper; it does not add or change a zarr.Array.lazy accessor or claim full NumPy advanced-indexing compatibility.

Validation

  • Full zarr-indexing suite with Dask installed, including doctests: 1,692 passed.
  • Recording-source tests verify indexing and iteration defer reads.
  • Write tests cover NumPy and Zarr sources, composed selections, negative strides, duplicate destinations, broadcasting, source aliases, masks, empty selections, and randomized selection chains checked against an independent coordinate oracle. New tests count chunk-store round trips for orthogonal, vectorized, and duplicate writes on plain and sharded zarr arrays, a write grid coarser than the read grid, and every fallback path.
  • Independent review ran 2,500 API-level and 3,000 direct transform trials plus an exhaustive 1,536-case sweep of the affine fast path with no failures.
  • A second independent review of the literal-domain change ran about 4,000 randomized views (840 with nonzero origins, 380 negative) over numpy, masked, and zarr sources, checking reads, part assembly, nested parts, writes, the eager adapter, and tokens against the previous behavior, with no failures.
  • Strict MkDocs build, pre-commit hooks, and Pyright (zero errors) pass.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the needs release notes Automatically applied to PRs which haven't added release notes label Sep 13, 2026
@read-the-docs-community

read-the-docs-community Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

@codecov

codecov Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.34%. Comparing base (2a704a7) to head (65d1afb).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4350   +/-   ##
=======================================
  Coverage   94.34%   94.34%           
=======================================
  Files          92       92           
  Lines       12948    12948           
=======================================
  Hits        12216    12216           
  Misses        732      732           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

d-v-b and others added 2 commits September 16, 2026 08:56
Add synchronous writes through composed selections and an explicit eager adapter for array consumers. Cover deferred reads, source mutation, masks, aliases, and repeated destinations.

Assisted-by: Codex:GPT-6
Rebased-onto: upstream/main after zarr-developers#4345-zarr-developers#4349 merged; conflicts resolved with ClaudeCode:claude-fable-5-1
The non-affine write fallback assigned one element at a time, which on a
chunked source is one chunk read-modify-write per element: a 50-row
orthogonal write into a 1000x1000 zarr array with 100x100 chunks cost
49,999 chunk writes and 27 s against 100 writes and 10 ms natively.

`write_into` now scatters in bulk. NumPy sources get one fancy assignment.
A readable source with a write grid, which `LazyArray.write` discovers
from `write_chunk_sizes` then `chunks`, is written one cell at a time:
the cell's touched hull is read once, updated in memory with
last-occurrence-wins semantics, and written back with one basic slice.
The same write into zarr now costs 100 chunk writes. Sources with no
grid, transforms the planner cannot factor, and sources that cannot be
read keep the per-element path, which never reads.

Also: order the rank check before the value copy while still validating
values for an empty selection; keep the payload of a masked zero-rank
affine write, which `np.flip` with no axes was turning into the `masked`
singleton; note that writes bypass the reader and pin that with a test;
note that iterated elements are views; add API pages for the writer and
eager adapter; and describe the shipped indexing surface in the
unreleased `LazyArray` changelog fragment instead of its removed `.lazy`
accessor.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@d-v-b
d-v-b force-pushed the codex/indexing-lazy-default branch from a6f9949 to 70db76d Compare September 16, 2026 07:27
@github-actions github-actions Bot added the zarr-indexing Specific to the zarr-indexing sub-package label Sep 16, 2026
d-v-b and others added 2 commits September 16, 2026 10:53
`LazyArray` re-zeroed the domain of every derived view, so a view forgot
which coordinates it was cut from and placement needed a side channel.
TensorStore keeps literal coordinates on views; NumPy users expect
positional keys. These are separable: the key dialect says how a key is
read, the domain says what the view remembers.

Views now keep their literal domain: `a[10:20]` has domain `[10, 20)`
and `a[10:20][2:5]` has `[12, 15)`, while `a[10:20][0]` is still the
first element because positional keys are normalized against the
domain's origin, which `normalize_positional_selection` already did.
Two literal keys join the NumPy ones, as in TensorStore's `__getitem__`:
an `IndexDomain` restricts the view to coordinates of its own domain
(empty intervals outside it are refused too) and an `IndexTransform`
composes onto it. Box partitions keep the request's coordinates, so a
part view's domain is a sub-domain of its parent's; a part placed by
index arrays keeps a fresh zero-origin domain, and `out_selection` is
the placement in both cases. A reversed view shows the negative origin
the algebra already produced.

Two frames stay zero-origin by construction so no consumer has to
subtract an origin: `ReadContext` re-bases its transform, and `parts()`
re-bases each projection's `cell_transform` from the request's literal
domain to positions in the view's result buffer, which is what
`Partition.projection` has always documented.

A table-driven test states the expected literal domain for every
indexing form and nested chain, checked against the transform algebra.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…dexing

Make the one rule about indexing a LazyArray prominent: NumPy keys are
positions relative to the view, an `IndexDomain` key names absolute
coordinates of the view's domain, an `IndexTransform` key composes onto
it, no key type has two readings, and every view keeps its absolute
domain whichever key produced it. A guide section with a runnable
snippet carries the table and the pandas `ix` / TensorStore comparison;
the module docstring, README, and API page point at it.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@d-v-b
d-v-b marked this pull request as ready for review September 16, 2026 09:26
@d-v-b
d-v-b merged commit b6c592e into zarr-developers:main Sep 16, 2026
39 checks passed
d-v-b added a commit that referenced this pull request Sep 29, 2026
* chore(zarr-indexing): render misc fragments and link entries to pull requests

Towncrier's built-in misc type sets showcontent = false, so the package's
misc fragments (#4346, #4364, #4406) would render as bare links. Restate
the five fragment types as zarr-metadata does, with misc showing its
content. Fragments are numbered by the merging PR, so link them under
/pull/ as zarr-metadata does.

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

* docs(zarr-indexing): backfill the 0.1.0, 0.2.0 and 0.2.1 release notes

The three releases were tagged without running towncrier, so CHANGELOG.md
stayed empty and their fragments stayed in changes/, where the next build
would have announced them again as new. Build each release from the
fragments its tag shipped: #3906's (renumbered #4196, the PR that merged
it) for 0.1.0, #4222's for 0.2.0, and a new #4252 entry for 0.2.1, whose
only change was the docs build fix. Dates are the tag dates.

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

* chore(zarr-indexing): build 0.3.0 changelog

Consume the pending fragments into CHANGELOG.md for the
zarr_indexing-v0.3.0 release.

Minor, not patch: #4350 makes LazyArray indexing lazy by default, so
iterating a view yields views rather than values, and views keep their
literal domain instead of re-zeroing after each selection; #4310 rejects
hand-built diagonal transforms with ValueError.

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

* chore(zarr-indexing): date the 0.3.0 release 2026-09-29

Assisted-by: ClaudeCode:claude-opus-5-5
d-v-b added a commit to d-v-b/zarr-python that referenced this pull request Sep 30, 2026
…ebase

Main removed the S101 exclusion for runtime code (zarr-developers#4364), made the
package's pytest configuration standalone (zarr-developers#4406), and dropped the
`.lazy` accessor (zarr-developers#4350). Narrow the basic-selection branch structurally
instead of asserting, raise on benchmark self-checks, drive the codec
integration tests through zarr's `sync` helper rather than the
pytest-asyncio marker the standalone config does not declare, and index
the view directly in the execution property test.

Assisted-by: ClaudeCode:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 zarr-indexing Specific to the zarr-indexing sub-package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant