Skip to content

Commit ee341db

Browse files
committed
fix(indexing): plan every view read with its source grid
Remove Partition.result and partition-local source windows. Preserve the source grid in derived views and supply projections for every LazyArray reader call, including unpartitioned reads. Assisted-by: Codex:GPT-6
1 parent c2173b4 commit ee341db

11 files changed

Lines changed: 81 additions & 118 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Add `Partition.result()` to execute a planned partition independently with the same global transform and chunk projection supplied during parent assembly.
1+
Make every `LazyArray` read supply a chunk projection, including unpartitioned reads and independently executed partition views. Partition views retain the source grid and full source base shape, so indexing and repartitioning use the same coordinate frame as other views.

packages/zarr-indexing/docs/api/lazy_array.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ coordinates into its raw `Partition.view.array`, including for non-first
2121
partitions. `Partition.projection.chunk_transform` intentionally stays local to
2222
the selected chunk. During parent materialization (`view.result(parts=parts)`) the reader receives
2323
both frames in one `ReadContext`: the public global transform in `context.transform` and the
24-
same local plan in `context.projection`. Use `part.result()` to execute a
25-
partition independently with both frames. Direct `part.view.result()` calls
26-
resolve the general view with no projection.
24+
local plan in `context.projection`. Every view retains the source grid and plans
25+
its reads, so `part.view.result()` also supplies both frames. Its projection's
26+
result placement is relative to that part view, rather than the parent output.
27+
Further indexing and repartitioning use the same source-global coordinate frame.
28+
Even `unpartitioned()` reads carry a projection for the single source-wide cell.
2729

2830
::: zarr_indexing.lazy_array

packages/zarr-indexing/docs/api/reader.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ ownership.
1212
`Reader.read_into(source, context, out)` receives a `ReadContext` whose
1313
`transform` maps zero-origin output-buffer coordinates to global coordinates in
1414
`source`, with `context.transform.domain.shape == out.shape`. Its optional
15-
`projection` is the existing plan for a partitioned read. Both parent
16-
assembly and independent `Partition.result()` calls supply that projection. The projection's
15+
`projection` describes one planned read. `LazyArray.result()` always supplies
16+
it, including for partition views and unpartitioned reads. Direct callers of
17+
the reader protocol may omit it when their reader supports that. The projection's
1718
`chunk_transform` remains chunk-local, its `cell_transform` describes result
1819
placement, and its `chunk_domain` describes the grid cell. The global read
1920
transform and the projection's chunk transform deliberately use different

packages/zarr-indexing/docs/guide/index.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -390,9 +390,10 @@ Within one `Partition`, the frames divide: `Partition.view.transform` is a
390390
different, global transform — it maps the part view directly into the raw
391391
wrapped source — while only `Partition.projection.chunk_transform` uses
392392
zero-origin chunk-local coordinates. Parent assembly passes both frames to
393-
the reader. Independently scheduled `part.result()` calls supply the same
394-
context. Calling `part.view.result()` instead resolves the view without the
395-
partition record, so its context has `projection=None`.
393+
the reader. Independently scheduled `part.view.result()` calls plan against the
394+
same source grid and supply both frames too. Their result placement is relative
395+
to the part view being read. A partition view can be indexed or repartitioned
396+
like any other view; its base shape remains the full source shape.
396397

397398
| Projection field | What its output coordinates mean |
398399
| --- | --- |

packages/zarr-indexing/docs/snippets/chunk_projection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def indices_to_chunks(self, indices: NDArray[np.intp]) -> NDArray[np.intp]:
5151
ADVANCED_EXPECTED = image[[4, 1, 1]][:, 2:6]
5252
ADVANCED_RESULT = np.empty_like(ADVANCED_EXPECTED)
5353
for part in advanced.parts():
54-
ADVANCED_RESULT[part.out_selection] = part.result()
54+
ADVANCED_RESULT[part.out_selection] = part.view.result()
5555

5656
np.testing.assert_array_equal(ADVANCED_RESULT, ADVANCED_EXPECTED)
5757
# --8<-- [end:advanced-projection]

packages/zarr-indexing/examples/lazy_indexing_dask/lazy_indexing_dask.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_parts_as_tasks(source: zarr.Array) -> None:
6666
# places the returned blocks sequentially.
6767
@dask.delayed
6868
def read(part: object) -> np.ndarray:
69-
return part.result()
69+
return part.view.result()
7070

7171
blocks = dask.compute(*[read(part) for part in parts], scheduler="threads")
7272

packages/zarr-indexing/examples/lazy_indexing_numpy/lazy_indexing_numpy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def test_parts() -> None:
105105
# parts and placing them is what `result()` does.
106106
assembled = np.empty(view.shape, dtype=view.dtype)
107107
for part in parts:
108-
assembled[part.out_selection] = part.result()
108+
assembled[part.out_selection] = part.view.result()
109109
assert np.array_equal(assembled, view.result())
110110

111111
# The partitioning is a read strategy, so a different one gives the same data.

packages/zarr-indexing/src/zarr_indexing/lazy_array.py

Lines changed: 27 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@
3030
3131
The part view's transform directly addresses its raw wrapped array. The paired
3232
projection deliberately retains the chunk-local frame. Parent materialization
33-
passes both in `ReadContext`, as does independent `part.result()` execution.
34-
Calling `part.view.result()` resolves the general view without its partition
35-
record and therefore uses `projection=None`. Use `part.result()` when scheduling
36-
partition reads.
33+
passes both in `ReadContext`. Every view retains the source's partitioning and
34+
plans its own reads, including views obtained from partitions. Calling
35+
`part.view.result()` therefore supplies the reader with the selected chunk's
36+
projection as well. Output placement is relative to the view being executed.
3737
3838
The partitioning is discovered from the wrapped array at construction — first
3939
`read_chunk_sizes` (zarr's clipped per-axis sizes, sharding-aware), then
@@ -406,8 +406,8 @@ class Partition:
406406
407407
Yielded by [`LazyArray.parts`][zarr_indexing.lazy_array.LazyArray.parts].
408408
The parts of a view tile it exactly and disjointly: assembling every
409-
`part.result()` at its `out_selection` reproduces the whole view's
410-
`result()`, with the same reader context on both execution paths. Parts can be
409+
`part.view.result()` at its `out_selection` reproduces the whole view's
410+
`result()`. Each view plans its reads using the retained source grid. Parts can be
411411
resolved concurrently when the source and reader permit it.
412412
Derived parts retain the same reader object; a shared stateful reader owns
413413
synchronization for concurrent calls.
@@ -448,12 +448,12 @@ class Partition:
448448
view
449449
A `LazyArray` covering exactly the cells of the view that live in this
450450
box. Its transform directly addresses its raw wrapped `array`; only the
451-
projection's `chunk_transform` is chunk-local. Use `Partition.result()` to read
452-
with this partition's projection through the selected reader. Named `view` rather than
451+
projection's `chunk_transform` is chunk-local. The view retains the source
452+
grid and reader and can be read or indexed like any other view. Named `view` rather than
453453
`array` because `LazyArray.array` is the opposite thing — the raw
454454
wrapped source — and the two sat next to each other meaning inverses.
455455
out_selection
456-
Where `Partition.result()` belongs in an array of the whole view's shape — a
456+
Where `view.result()` belongs in an array of the whole view's shape — a
457457
NumPy index tuple with one entry per dimension of the view, usable
458458
directly as `out[part.out_selection] = ...`.
459459
is_complete
@@ -472,7 +472,7 @@ class Partition:
472472
>>> view = LazyArray.from_numpy(source).with_parts((2, 2))
473473
>>> out = np.empty(view.shape, dtype=view.dtype)
474474
>>> for part in view.parts():
475-
... out[part.out_selection] = part.result()
475+
... out[part.out_selection] = part.view.result()
476476
>>> bool((out == source).all())
477477
True
478478
"""
@@ -493,34 +493,6 @@ def is_complete(self) -> bool:
493493
"""Whether the projection proves it covers the entire selected cell."""
494494
return self.projection.coverage == "full"
495495

496-
def result(self) -> Any:
497-
"""Materialize this partition with its existing projection.
498-
499-
Returns
500-
-------
501-
numpy.ndarray
502-
The selected values in fresh system memory, with this part's view
503-
shape and dtype. Assign the result at `out_selection` to assemble
504-
the parent view. The reader receives the same global transform and
505-
chunk projection as in parent materialization.
506-
507-
Notes
508-
-----
509-
This uses the part's reader and source without replanning. Backend
510-
exceptions propagate unchanged. Concurrent calls require a source and
511-
reader that support concurrent access.
512-
"""
513-
# Share the view allocator so independent reads preserve masked-array output.
514-
out = self.view._output_buffer(self.view.shape) # pyright: ignore[reportPrivateUsage]
515-
if math.prod(self.view.shape) != 0:
516-
_invoke_reader(
517-
self.view.reader,
518-
self.view.array,
519-
ReadContext(self.view.transform, self.projection),
520-
out,
521-
)
522-
return out
523-
524496

525497
def _validate_prepared_parts(parts: Sequence[Partition], out_shape: tuple[int, ...]) -> None:
526498
"""Require `parts` to address every output cell exactly once.
@@ -598,7 +570,7 @@ class LazyArray:
598570
[ 8, 10]])
599571
"""
600572

601-
__slots__ = ("_array", "_part_owner", "_parts", "_reader", "_transform", "_window")
573+
__slots__ = ("_array", "_part_owner", "_parts", "_reader", "_transform")
602574

603575
def __init__(self, array: _WrappedArray) -> None:
604576
"""Wrap `array` without reading it; parameters are documented on the class.
@@ -619,7 +591,6 @@ def __init__(self, array: _WrappedArray) -> None:
619591
)
620592
shape = tuple(int(s) for s in array.shape)
621593
self._array = array
622-
self._window: tuple[slice, ...] | None = None
623594
self._transform = IndexTransform.from_shape(shape)
624595
self._parts = _discover_parts(array, shape)
625596
self._reader = basic_reader
@@ -640,7 +611,6 @@ def _derive(
640611
array: _WrappedArray,
641612
transform: IndexTransform,
642613
parts: tuple[DimensionGrid, ...] | None,
643-
window: tuple[slice, ...] | None,
644614
reader: Reader,
645615
) -> LazyArray:
646616
"""Build a wrapper sharing `array` but carrying a new transform or partitioning."""
@@ -650,17 +620,14 @@ def _derive(
650620
# view's first element is at position 0 whatever it was sliced from.
651621
view._transform = transform.translate_domain_to((0,) * transform.input_rank)
652622
view._parts = parts
653-
view._window = window
654623
view._reader = reader
655624
view._part_owner = _PartOwner()
656625
return view
657626

658627
@property
659628
def _base_shape(self) -> tuple[int, ...]:
660629
"""The shape of what this wrapper treats as its base array."""
661-
if self._window is None:
662-
return tuple(int(s) for s in self._array.shape)
663-
return tuple(s.stop - s.start for s in self._window)
630+
return tuple(int(s) for s in self._array.shape)
664631

665632
# -- array-like surface -------------------------------------------------
666633

@@ -674,11 +641,8 @@ def base_shape(self) -> tuple[int, ...]:
674641
"""The shape the partitioning is expressed in — not this view's shape.
675642
676643
`with_parts` and `with_parts_per_axis` describe boxes of the array being
677-
read, not of the view reading it, so a narrowed view still partitions
678-
the extents named here. For a part's own `array`, this is the part's
679-
box, which is why the same call means different sizes there. Without
680-
somewhere to read it, the frame in force could only be inferred from an
681-
error message.
644+
read, not of the view reading it. All derived views, including partition
645+
views, retain the full source shape as their partitioning frame.
682646
"""
683647
return self._base_shape
684648

@@ -720,7 +684,6 @@ def with_reader(self, reader: Reader) -> LazyArray:
720684
self._array,
721685
self._transform,
722686
self._parts,
723-
self._window,
724687
reader,
725688
)
726689

@@ -949,9 +912,8 @@ def _part_entries(parts: Sequence[Any], method: str) -> tuple[Any, ...]:
949912
def unpartitioned(self) -> LazyArray:
950913
"""Return the same view, read in one pass.
951914
952-
`result()` still allocates its owned output buffer first, then calls the
953-
reader once with the whole projected transform. `parts()` still yields a
954-
single part covering everything.
915+
The source is treated as a single grid cell. Nonempty reads still pass
916+
its projection to the reader; empty reads make no reader calls.
955917
956918
Returns
957919
-------
@@ -961,7 +923,7 @@ def unpartitioned(self) -> LazyArray:
961923
return self._with_grids(None)
962924

963925
def _with_grids(self, grids: tuple[DimensionGrid, ...] | None) -> LazyArray:
964-
return LazyArray._derive(self._array, self._transform, grids, self._window, self._reader)
926+
return LazyArray._derive(self._array, self._transform, grids, self._reader)
965927

966928
def parts(self) -> Iterator[Partition]:
967929
"""Iterate the base partitioning, projected through this view.
@@ -972,7 +934,7 @@ def parts(self) -> Iterator[Partition]:
972934
973935
Yields one [`Partition`][zarr_indexing.lazy_array.Partition] per box the
974936
view actually touches. The parts tile the view exactly and disjointly,
975-
and each can be resolved with `Partition.result()`: in another thread,
937+
and each view can be resolved with `part.view.result()`: in another thread,
976938
in another order, or not at all. Those views share this
977939
view's reader, and `LazyArray` does not serialize calls, so a stateful
978940
reader must synchronize its own mutable state.
@@ -993,48 +955,16 @@ def parts(self) -> Iterator[Partition]:
993955
>>> (part.base_coords, part.view.shape, part.is_complete)
994956
((0, 0), (2, 1), False)
995957
"""
996-
base_shape = self._base_shape
997-
grids = self._parts if self._parts is not None else _whole_array_grids(base_shape)
998-
rank = len(base_shape)
999-
1000-
if self._window is None:
1001-
plan_transform = self._transform
1002-
else:
1003-
plan_transform = self._transform.translate(tuple(-item.start for item in self._window))
1004-
1005-
for projection in plan_chunks(plan_transform, grids):
1006-
base_coords = projection.chunk_coords
1007-
local = projection.chunk_transform
1008-
origin = tuple(grid.chunk_offset(c) for grid, c in zip(grids, base_coords, strict=True))
1009-
extent = tuple(grid.data_size(c) for grid, c in zip(grids, base_coords, strict=True))
1010-
if origin == (0,) * rank and extent == base_shape:
1011-
# The part is the whole base: lowering directly against the
1012-
# source beats materializing a block that is the source.
1013-
window = self._window
1014-
elif self._window is None:
1015-
window = tuple(slice(o, o + e) for o, e in zip(origin, extent, strict=True))
1016-
else:
1017-
window = tuple(
1018-
slice(w.start + o, w.start + o + e)
1019-
for w, o, e in zip(self._window, origin, extent, strict=True)
1020-
)
1021-
# The global box, computed from the origin directly rather than from
1022-
# `window`: a part covering the whole base carries no window (so
1023-
# nothing is pre-materialized) but still sits somewhere concrete.
1024-
if self._window is None:
1025-
global_origin = origin
1026-
else:
1027-
global_origin = tuple(
1028-
w.start + o for w, o in zip(self._window, origin, strict=True)
1029-
)
958+
grids = self._parts if self._parts is not None else _whole_array_grids(self._base_shape)
959+
for projection in plan_chunks(self._transform, grids):
960+
domain = projection.chunk_domain
1030961
yield Partition(
1031962
projection=projection,
1032-
box=tuple((o, o + e) for o, e in zip(global_origin, extent, strict=True)),
963+
box=tuple(zip(domain.inclusive_min, domain.exclusive_max, strict=True)),
1033964
view=LazyArray._derive(
1034965
self._array,
1035-
local.translate(global_origin),
1036-
None,
1037-
window,
966+
projection.chunk_transform.translate(domain.inclusive_min),
967+
self._parts,
1038968
self._reader,
1039969
),
1040970
out_selection=_partition_out_selection(projection.cell_transform),
@@ -1068,7 +998,7 @@ def _select(self, selection: Any, mode: SelectionMode) -> LazyArray:
1068998
composed = transform[literal]
1069999
else:
10701000
composed = transform.select(literal, mode)
1071-
return LazyArray._derive(self._array, composed, self._parts, self._window, self._reader)
1001+
return LazyArray._derive(self._array, composed, self._parts, self._reader)
10721002

10731003
def __getitem__(self, selection: Any) -> Any:
10741004
"""Read a basic selection eagerly, like `numpy.ndarray.__getitem__`.
@@ -1086,6 +1016,8 @@ def result(self, *, parts: Sequence[Partition] | None = None) -> Any:
10861016
partition is read through the selected reader directly into its
10871017
rectangular destination, or into an owned dense temporary before fancy
10881018
placement. Empty views allocate without reading the source.
1019+
Every reader call includes a chunk projection, even when the source is
1020+
treated as a single grid cell by `unpartitioned()`.
10891021
10901022
Parameters
10911023
----------
@@ -1128,10 +1060,6 @@ def result(self, *, parts: Sequence[Partition] | None = None) -> Any:
11281060
if size == 0:
11291061
return out
11301062

1131-
if prepared_parts is None and self._parts is None:
1132-
_invoke_reader(self._reader, self._array, ReadContext(self._transform), out)
1133-
return out
1134-
11351063
written = 0
11361064
selected_parts = self.parts() if prepared_parts is None else prepared_parts
11371065
for part in selected_parts:

packages/zarr-indexing/src/zarr_indexing/reader.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ class ReadContext:
4848
"""Maps zero-origin output-buffer coordinates to global coordinates in the source."""
4949

5050
projection: ChunkProjection | None = None
51-
"""The partition plan when this read is one part of a partitioned view, else `None`."""
51+
"""The read plan, always supplied by `LazyArray` execution.
52+
53+
Direct reader callers may omit it if their reader supports unplanned reads.
54+
"""
5255

5356

5457
class Reader(Protocol):

0 commit comments

Comments
 (0)