diff --git a/.github/workflows/check_changelogs.yml b/.github/workflows/check_changelogs.yml index 0033b43db2..d7a54fc2c4 100644 --- a/.github/workflows/check_changelogs.yml +++ b/.github/workflows/check_changelogs.yml @@ -29,3 +29,6 @@ jobs: - name: Check zarr-metadata changelog entries run: uv run --no-sync python ci/check_changelog_entries.py packages/zarr-metadata/changes + + - name: Check zarr-indexing changelog entries + run: uv run --no-sync python ci/check_changelog_entries.py packages/zarr-indexing/changes diff --git a/.github/workflows/zarr-indexing-release.yml b/.github/workflows/zarr-indexing-release.yml new file mode 100644 index 0000000000..7cfd571eae --- /dev/null +++ b/.github/workflows/zarr-indexing-release.yml @@ -0,0 +1,117 @@ +name: zarr-indexing release + +on: + workflow_dispatch: + push: + tags: + - 'zarr_indexing-v*' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build wheel and sdist + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-indexing + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 # hatch-vcs needs full history + tags + + - name: Install Hatch + uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc + with: + version: '1.16.5' + + - name: Build + run: hatch build + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zarr-indexing-dist + path: packages/zarr-indexing/dist + + test_artifacts: + name: Test built artifacts + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zarr-indexing-dist + path: dist + + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: false + + - name: Set up Python + run: uv python install 3.12 + + - name: Install built wheel and run import smoke test + run: | + wheel=$(ls dist/*.whl) + uv run --with "${wheel}" --python 3.12 --no-project \ + python -c "import zarr_indexing; print('zarr_indexing', zarr_indexing.__version__)" + + upload_pypi: + name: Upload to PyPI + needs: [build, test_artifacts] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/zarr_indexing-v') + runs-on: ubuntu-latest + environment: + name: zarr-indexing-releases + url: https://pypi.org/p/zarr-indexing + permissions: + id-token: write # required for OIDC trusted publishing + attestations: write # required for artifact attestations + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zarr-indexing-dist + path: dist + + - name: Generate artifact attestation + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: dist/* + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + + upload_testpypi: + name: Upload to TestPyPI + needs: [build, test_artifacts] + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + environment: + name: zarr-indexing-releases-test + url: https://test.pypi.org/p/zarr-indexing + permissions: + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zarr-indexing-dist + path: dist + + - name: Generate artifact attestation + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: dist/* + + - name: Publish package to TestPyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/zarr-indexing.yml b/.github/workflows/zarr-indexing.yml new file mode 100644 index 0000000000..ce798e0924 --- /dev/null +++ b/.github/workflows/zarr-indexing.yml @@ -0,0 +1,102 @@ +name: zarr-indexing + +on: + push: + branches: [main] + paths: + - 'packages/zarr-indexing/**' + - '.github/workflows/zarr-indexing.yml' + pull_request: + paths: + - 'packages/zarr-indexing/**' + - '.github/workflows/zarr-indexing.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: pytest py=${{ matrix.python-version }} + runs-on: ubuntu-latest + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + python-version: ['3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + # The transform tests exercise chunk resolution against zarr's ChunkGrid, + # so they run from the repo root against the full workspace (which installs + # both `zarr` and the `zarr-indexing` workspace member) rather than in + # package isolation. + - name: Sync test dependency group + run: uv sync --all-packages --group test --python ${{ matrix.python-version }} + - name: Run pytest + run: uv run --no-sync --group test pytest packages/zarr-indexing/tests + + ruff: + name: ruff + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-indexing + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - name: Run ruff + run: uvx ruff check . + + pyright: + name: pyright + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-indexing + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + - name: Set up Python + run: uv python install 3.12 + - name: Sync test dependency group + run: uv sync --group test --python 3.12 + - name: Run pyright + run: uv run --group test --with pyright pyright src + + zarr-indexing-complete: + name: zarr-indexing complete + needs: [test, ruff, pyright] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check failure + if: | + contains(needs.*.result, 'failure') || + contains(needs.*.result, 'cancelled') + run: exit 1 + - name: Success + run: echo Success! diff --git a/packages/zarr-indexing/CHANGELOG.md b/packages/zarr-indexing/CHANGELOG.md new file mode 100644 index 0000000000..7c4bc92cad --- /dev/null +++ b/packages/zarr-indexing/CHANGELOG.md @@ -0,0 +1,3 @@ +# Release notes + + diff --git a/packages/zarr-indexing/LICENSE.txt b/packages/zarr-indexing/LICENSE.txt new file mode 100644 index 0000000000..1e8da4d242 --- /dev/null +++ b/packages/zarr-indexing/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2025 Zarr Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/zarr-indexing/README.md b/packages/zarr-indexing/README.md new file mode 100644 index 0000000000..86029614e9 --- /dev/null +++ b/packages/zarr-indexing/README.md @@ -0,0 +1,30 @@ +# zarr-indexing + +Composable, lazy coordinate transforms for Zarr array indexing. + +This package implements TensorStore-inspired index transforms. The core idea: +every indexing operation (slicing, fancy indexing, etc.) produces a coordinate +mapping from user space to storage space. These mappings compose lazily — no +I/O until you explicitly read or write. + +Key types: + +- `IndexDomain` — a rectangular region of integer coordinates +- `IndexTransform` — maps input coordinates to storage coordinates +- `ConstantMap`, `DimensionMap`, `ArrayMap` — the three ways a single output + dimension can depend on the input +- `compose` — chain two transforms into one + +The package depends only on NumPy and the standard library; it does not import +`zarr`. It is developed in the [zarr-python](https://github.com/zarr-developers/zarr-python) +repository and consumed by `zarr` to resolve array indexing operations. + +## Installation + +```bash +pip install zarr-indexing +``` + +## License + +MIT diff --git a/packages/zarr-indexing/changes/3906.feature.md b/packages/zarr-indexing/changes/3906.feature.md new file mode 100644 index 0000000000..ca39164018 --- /dev/null +++ b/packages/zarr-indexing/changes/3906.feature.md @@ -0,0 +1 @@ +Reworked the JSON layer to conform to the [ndsel](https://github.com/d-v-b/ndsel) draft wire format, which adapts TensorStore's `IndexTransform`. A new `zarr_indexing.messages` module (`parse_ndsel`, `normalize_ndsel`, `NdselError`) is a pure JSON-to-JSON layer that accepts all five message kinds (`point`/`box`/`slice`/`points`/`transform`) and normalizes them to the canonical transform body, enforcing the full ndsel error taxonomy. The package is checked against the vendored, language-agnostic ndsel conformance corpus. `index_transform_to_json`/`index_transform_from_json` (and the domain variants) now produce and consume the canonical body. On serialization, orthogonal (`oindex`) `index_array` maps no longer emit `input_dimension` alongside `index_array` (a combination both ndsel and TensorStore reject), and degenerate all-singleton index arrays collapse to constant maps; the in-memory `input_dimension` is reconstructed from the array's dependency axes on load. diff --git a/packages/zarr-indexing/changes/README.md b/packages/zarr-indexing/changes/README.md new file mode 100644 index 0000000000..feb3f8674e --- /dev/null +++ b/packages/zarr-indexing/changes/README.md @@ -0,0 +1,25 @@ +Writing a changelog entry for `zarr-indexing` +----------------------------------------------- + +Fragments in **this** directory are release notes for the `zarr-indexing` +package only — kept separate from the parent zarr-python `changes/` +directory so a PR touching only `packages/zarr-indexing/` produces a +release note for this package only. + +Please put a new file in this directory named `xxxx..md`, where + +- `xxxx` is the pull request number associated with this entry +- `` is one of: + - feature + - bugfix + - doc + - removal + - misc + +Inside the file, please write a short description of what you have +changed, and how it impacts users of `zarr-indexing`. + +A `zarr-indexing` release runs `towncrier build` in `packages/zarr-indexing/`, +which consumes the fragments here and updates `CHANGELOG.md`. Fragments +that describe parent zarr-python changes (not the transforms package) +belong in the top-level `changes/` directory, not here. diff --git a/packages/zarr-indexing/pyproject.toml b/packages/zarr-indexing/pyproject.toml new file mode 100644 index 0000000000..f29afe2f4a --- /dev/null +++ b/packages/zarr-indexing/pyproject.toml @@ -0,0 +1,113 @@ +[build-system] +requires = ["hatchling>=1.29.0", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "zarr-indexing" +dynamic = ["version"] +description = "Composable, lazy coordinate transforms for Zarr array indexing." +readme = "README.md" +requires-python = ">=3.12" +license = "MIT" +license-files = ["LICENSE.txt"] +authors = [ + { name = "Davis Bennett", email = "davis.v.bennett@gmail.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +keywords = ["zarr"] +dependencies = [ + "numpy>=2", +] + +[project.urls] +Homepage = "https://github.com/zarr-developers/zarr-python" +Source = "https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-indexing" +Issues = "https://github.com/zarr-developers/zarr-python/issues" +Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/CHANGELOG.md" +Documentation = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/README.md" + +[dependency-groups] +# The transform tests exercise chunk resolution against zarr's ChunkGrid +# (tests/test_chunk_resolution.py) and are collected by the parent zarr-python +# test suite, which already has zarr installed. `zarr` is intentionally NOT +# listed here to avoid a workspace dependency cycle; run these tests from the +# repo root (`uv run pytest packages/zarr-indexing/tests`), not in isolation. +test = ["pytest"] + +[tool.hatch.version] +source = "vcs" +tag-pattern = '^zarr_indexing-v(?P.+)$' +# `git_describe_command` ensures we get the zarr_indexing tags instead of latest. +# `local_scheme` strips the git commit info so the appending info is just a counter from latest tag. +# test-pypi doesn't accept git commit info in tags, and the count should be enough to distinguish unique runs. +raw-options = { root = "../..", git_describe_command = "git describe --dirty --tags --long --match zarr_indexing-v*", local_scheme = "no-local-version" } + +[tool.hatch.build.targets.wheel] +packages = ["src/zarr_indexing"] + +[tool.ruff] +extend = "../../pyproject.toml" +target-version = "py312" + +[tool.pytest.ini_options] +minversion = "7" +testpaths = ["tests"] +xfail_strict = true +addopts = ["-ra", "--strict-config", "--strict-markers"] +filterwarnings = [ + "error", +] + +[tool.pyright] +include = ["src"] +enableExperimentalFeatures = true +typeCheckingMode = "strict" +pythonVersion = "3.12" +# This strict config was written for zarr-metadata's JSON/dataclass-shaped +# code. zarr-indexing is numpy-heavy, and numpy's stubs return partially +# unknown types (e.g. `ndarray[Unknown, Unknown]`, `dtype[Unknown]`) even for +# fully-typed call sites, so the reportUnknown* family below cannot reasonably +# be satisfied here. Downgraded to warnings (not silenced) rather than +# disabled outright, and CI (which only fails the pyright job on errors, not +# warnings) still surfaces them for visibility. +reportUnknownVariableType = "warning" +reportUnknownArgumentType = "warning" +reportUnknownMemberType = "warning" +reportUnknownParameterType = "warning" + +[tool.numpydoc_validation] +checks = [ + "GL10", + "SS04", + "PR02", + "PR03", + "PR05", + "PR06", +] + +[tool.towncrier] +# Fragments for this package live alongside the package source, separate +# from the parent zarr-python `changes/` directory, so a PR touching only +# `packages/zarr-indexing/` produces a release note for this package only. +directory = "changes" +filename = "CHANGELOG.md" +package = "zarr_indexing" +underlines = ["", "", ""] +title_format = "## {version} ({project_date})" +issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/issues/{issue})" +start_string = "\n" diff --git a/packages/zarr-indexing/src/zarr_indexing/__init__.py b/packages/zarr-indexing/src/zarr_indexing/__init__.py new file mode 100644 index 0000000000..effe38ca88 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/__init__.py @@ -0,0 +1,74 @@ +"""Composable, lazy coordinate transforms for zarr array indexing. + +This package implements TensorStore-inspired index transforms. The core idea: +every indexing operation (slicing, fancy indexing, etc.) produces a coordinate +mapping from user space to storage space. These mappings compose lazily — no +I/O until you explicitly read or write. + +Key types: + +- `IndexDomain` — a rectangular region of integer coordinates +- `IndexTransform` — maps input coordinates to storage coordinates +- `ConstantMap`, `DimensionMap`, `ArrayMap` — the three ways a single + output dimension can depend on the input (see `output_map.py`) +- `compose` — chain two transforms into one + +The chunk-resolution helpers (`iter_chunk_transforms`, +`sub_transform_to_selections`) and `selection_to_transform` are also exported +here: they form the surface the zarr integration layer (array indexing) depends +on. The `*Like` grid Protocols describe the chunk-grid surface chunk resolution +consumes without importing zarr. +""" + +from importlib.metadata import version + +from zarr_indexing.chunk_resolution import ( + iter_chunk_transforms, + sub_transform_to_selections, +) +from zarr_indexing.composition import compose +from zarr_indexing.domain import IndexDomain +from zarr_indexing.grid import DimensionGridLike +from zarr_indexing.json import ( + IndexDomainJSON, + IndexTransformJSON, + OutputIndexMapJSON, + index_domain_from_json, + index_domain_to_json, + index_transform_from_json, + index_transform_to_json, + transform_from_canonical, + transform_to_canonical, +) +from zarr_indexing.messages import NdselError, normalize_ndsel, parse_ndsel +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap +from zarr_indexing.transform import IndexTransform, selection_to_transform + +__version__ = version("zarr-indexing") + +__all__ = [ + "ArrayMap", + "ConstantMap", + "DimensionGridLike", + "DimensionMap", + "IndexDomain", + "IndexDomainJSON", + "IndexTransform", + "IndexTransformJSON", + "NdselError", + "OutputIndexMap", + "OutputIndexMapJSON", + "__version__", + "compose", + "index_domain_from_json", + "index_domain_to_json", + "index_transform_from_json", + "index_transform_to_json", + "iter_chunk_transforms", + "normalize_ndsel", + "parse_ndsel", + "selection_to_transform", + "sub_transform_to_selections", + "transform_from_canonical", + "transform_to_canonical", +] diff --git a/packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py b/packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py new file mode 100644 index 0000000000..7aea86ad02 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py @@ -0,0 +1,380 @@ +"""Chunk resolution — mapping transforms to chunk-level I/O. + +Given an `IndexTransform` (which coordinates a user wants to access) and a +`ChunkGrid` (how storage is divided into chunks), chunk resolution answers: + + For each chunk, which storage coordinates does this transform touch, + and where do those values land in the output buffer? + +The algorithm is: + +1. **Enumerate candidate chunks** — determine which chunks could possibly + be touched by the transform's output coordinate ranges. + +2. **Intersect** — for each candidate chunk, call + `transform.intersect(chunk_domain)` to restrict the transform to + coordinates within that chunk. If the intersection is empty, skip it. + +3. **Translate** — shift the restricted transform to chunk-local coordinates + via `transform.translate(-chunk_origin)`. + +4. **Yield** — produce `(chunk_coords, local_transform, surviving_indices)` + triples that the codec pipeline consumes. + +Sorted one-dimensional correlated array maps can be partitioned directly +because every touched chunk owns a contiguous slice of the index array. That +case bypasses candidate enumeration and repeated intersection. + +`sub_transform_to_selections` bridges from the transform representation +back to the raw `(chunk_selection, out_selection, drop_axes)` tuples that +the current codec pipeline expects. This bridge will go away when the codec +pipeline accepts transforms natively. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform + +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + from zarr_indexing.grid import DimensionGridLike + +OutIndices = ( + dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]] | None +) + +ChunkTransformResult = tuple[ + tuple[int, ...], + IndexTransform, + OutIndices, +] + + +def _one_dimensional_correlated_array_map( + transform: IndexTransform, +) -> tuple[ArrayMap, np.ndarray[Any, np.dtype[np.intp]]] | None: + """Return a nonempty correlated 1-D ArrayMap and its storage coordinates. + + A one-dimensional array selection has no cross-dimensional correlation to + preserve. The computed storage coordinates are also reused by general + resolution when they are unsorted. + """ + if transform.input_rank != 1 or transform.output_rank != 1: + return None + + m = transform.output[0] + if ( + not isinstance(m, ArrayMap) + or m.input_dimension is not None + or m.index_array.ndim != 1 + or m.index_array.size == 0 + ): + return None + + return m, m.offset + m.stride * m.index_array + + +def _iter_sorted_1d_array_map( + m: ArrayMap, + storage: np.ndarray[Any, np.dtype[np.intp]], + dim_grid: DimensionGridLike, +) -> Iterator[ChunkTransformResult]: + """Resolve a sorted 1-D ArrayMap one touched chunk at a time.""" + start = 0 + while start < storage.size: + chunk = dim_grid.index_to_chunk(int(storage[start])) + chunk_start = dim_grid.chunk_offset(chunk) + chunk_stop = chunk_start + dim_grid.chunk_size(chunk) + stop = int(np.searchsorted(storage, chunk_stop, side="left")) + + restricted = IndexTransform( + domain=IndexDomain(inclusive_min=(0,), exclusive_max=(stop - start,)), + output=( + ArrayMap( + index_array=m.index_array[start:stop], + offset=m.offset, + stride=m.stride, + input_dimension=m.input_dimension, + ), + ), + ) + local = restricted.translate((-chunk_start,)) + surviving = np.arange(start, stop, dtype=np.intp) + + yield (chunk,), local, surviving + start = stop + + +def iter_chunk_transforms( + transform: IndexTransform, + dim_grids: Sequence[DimensionGridLike], +) -> Iterator[ChunkTransformResult]: + """Resolve a composed IndexTransform against per-dimension chunk grids. + + `dim_grids` holds one `DimensionGridLike` per output (storage) dimension — + for zarr this is the chunk grid's per-dimension sequence. Yields + `(chunk_coords, sub_transform, out_indices)` triples: + + - `chunk_coords`: which chunk to access. + - `sub_transform`: maps output buffer coords to chunk-local coords. + - `out_indices`: for vectorized/array indexing, the output scatter + indices (integer array). `None` for basic/slice indexing. + """ + + array_map_1d = _one_dimensional_correlated_array_map(transform) + if array_map_1d is not None: + sorted_map, storage = array_map_1d + if storage[0] <= storage[-1] and bool(np.all(storage[1:] >= storage[:-1])): + dim_grid = dim_grids[0] + first_chunk = dim_grid.index_to_chunk(int(storage[0])) + if dim_grid.chunk_size(first_chunk) > 0: + yield from _iter_sorted_1d_array_map(sorted_map, storage, dim_grid) + return + + # Enumerate candidate chunks via the cartesian product of per-slot candidate + # chunk ids, then for each candidate intersect the transform with the chunk + # domain (`transform.intersect` handles orthogonal and vectorized cases + # alike, filtering out combinations it does not actually touch). + # + # A slot covers one or more output dimensions and contributes exactly the + # chunk-coordinate tuples those dimensions can touch: + # + # - `ConstantMap`/`DimensionMap` dims each form their own slot with a + # contiguous range — a single chunk for a constant, and the span between + # the first and last chunk for a slice. These are already tight (or + # nearly so). + # - Orthogonal `ArrayMap` (fancy) dims each form their own slot with only + # the *distinct* chunk ids the index array actually lands in + # (`np.unique`), never the dense `range(min_chunk, max_chunk + 1)` + # between them. A sparse fancy selection (e.g. two far-apart coordinates) + # would otherwise enumerate every chunk in the bounding box, making + # resolution scale with grid size instead of with the number of selected + # coordinates. + # - Correlated (vindex) `ArrayMap` dims share one *joint* slot holding the + # distinct chunk-coordinate tuples the points actually land in. The + # cartesian product of their per-dimension distinct sets would include + # combinations no point touches — quadratic in the number of selected + # points for a diagonal selection — while the joint distinct set is + # bounded by the point count (see zarr-python gh-4174). + correlated_dims: list[int] = [] + correlated_chunk_ids: list[np.ndarray[Any, np.dtype[np.intp]]] = [] + slot_dims: list[tuple[int, ...]] = [] + slot_candidates: list[Sequence[tuple[int, ...]]] = [] + for out_dim, m in enumerate(transform.output): + dg = dim_grids[out_dim] + if isinstance(m, ConstantMap): + # Single chunk + c = dg.index_to_chunk(m.offset) + slot_dims.append((out_dim,)) + slot_candidates.append(((c,),)) + elif isinstance(m, DimensionMap): + d = m.input_dimension + dim_lo = transform.domain.inclusive_min[d] + dim_hi = transform.domain.exclusive_max[d] + if dim_lo >= dim_hi: + return # empty domain + if m.stride > 0: + s_min = m.offset + m.stride * dim_lo + s_max = m.offset + m.stride * (dim_hi - 1) + else: + s_min = m.offset + m.stride * (dim_hi - 1) + s_max = m.offset + m.stride * dim_lo + first = dg.index_to_chunk(s_min) + last = dg.index_to_chunk(s_max) + slot_dims.append((out_dim,)) + slot_candidates.append([(c,) for c in range(first, last + 1)]) + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap). + # Storage coordinates were already computed for a correlated 1-D map. + storage = ( + array_map_1d[1] if array_map_1d is not None else m.offset + m.stride * m.index_array + ) + if storage.size == 0: + # Empty fancy selection: no coordinates, so no chunks are touched. + return + # Keep the index-array shape: correlated maps broadcast against each + # other below, and raveling first would lose the singleton axes. + chunk_ids = dg.indices_to_chunks(storage.astype(np.intp)) + if m.input_dimension is None: + correlated_dims.append(out_dim) + correlated_chunk_ids.append(chunk_ids) + else: + slot_dims.append((out_dim,)) + slot_candidates.append([(int(c),) for c in np.unique(chunk_ids)]) + + if len(correlated_dims) == 1: + slot_dims.append((correlated_dims[0],)) + slot_candidates.append([(int(c),) for c in np.unique(correlated_chunk_ids[0])]) + elif len(correlated_dims) >= 2: + # Group the points jointly: distinct rows of the per-point chunk + # coordinates, O(points log points) regardless of grid size. + broadcast = np.broadcast_arrays(*correlated_chunk_ids) + stacked = np.stack([b.ravel() for b in broadcast], axis=1) + joint = np.unique(stacked, axis=0) + slot_dims.append(tuple(correlated_dims)) + slot_candidates.append([tuple(int(c) for c in row) for row in joint]) + + import itertools + + output_rank = len(transform.output) + for combo in itertools.product(*slot_candidates): + chunk_coords_list = [0] * output_rank + for dims, part in zip(slot_dims, combo, strict=True): + for d, c in zip(dims, part, strict=True): + chunk_coords_list[d] = c + chunk_coords = tuple(chunk_coords_list) + + # Build the chunk domain in storage space + chunk_min: list[int] = [] + chunk_max: list[int] = [] + chunk_shift: list[int] = [] + for out_dim, c in enumerate(chunk_coords): + dg = dim_grids[out_dim] + c_start = dg.chunk_offset(c) + c_size = dg.chunk_size(c) + chunk_min.append(c_start) + chunk_max.append(c_start + c_size) + chunk_shift.append(-c_start) + + chunk_domain = IndexDomain( + inclusive_min=tuple(chunk_min), + exclusive_max=tuple(chunk_max), + ) + + # Intersect transform with chunk domain + result = transform.intersect(chunk_domain) + if result is None: + continue + + restricted, surviving = result + + # Translate to chunk-local coordinates + local = restricted.translate(tuple(chunk_shift)) + + yield (chunk_coords, local, surviving) + + +def sub_transform_to_selections( + sub_transform: IndexTransform, + out_indices: OutIndices = None, +) -> tuple[ + tuple[int | slice | np.ndarray[tuple[int, ...], np.dtype[np.intp]], ...], + tuple[slice | np.ndarray[tuple[int, ...], np.dtype[np.intp]], ...], + tuple[int, ...], +]: + """Convert a chunk-local sub-transform to raw selections for the codec pipeline. + + Parameters + ---------- + sub_transform + A chunk-local IndexTransform (output maps already translated to + chunk-local coordinates). + out_indices + For vectorized indexing: the output scatter indices for this chunk. + None for orthogonal/basic indexing. + + Returns + ------- + tuple + `(chunk_selection, out_selection, drop_axes)` + """ + inclusive_min = sub_transform.domain.inclusive_min + exclusive_max = sub_transform.domain.exclusive_max + + # Orthogonal outer product: >= 2 ArrayMaps each bound to a distinct input + # dimension. out_indices is a per-output-dim dict of surviving positions. The + # codec applies chunk_array[chunk_sel] / out[out_sel] with NumPy semantics, so + # build np.ix_-style selections (mirroring the legacy OrthogonalIndexer): one + # 1-D selector per dimension, expanded to an open mesh. ConstantMap dims are + # size-1 in chunk space and squeezed out via drop_axes. + if isinstance(out_indices, dict): + chunk_arrays: list[np.ndarray[Any, np.dtype[np.intp]]] = [] + out_arrays: list[np.ndarray[Any, np.dtype[np.intp]]] = [] + drop_axes: list[int] = [] + for out_dim, m in enumerate(sub_transform.output): + if isinstance(m, ConstantMap): + chunk_arrays.append(np.array([m.offset], dtype=np.intp)) + drop_axes.append(out_dim) + elif isinstance(m, DimensionMap): + rng = np.arange(inclusive_min[m.input_dimension], exclusive_max[m.input_dimension]) + chunk_arrays.append((m.offset + m.stride * rng).astype(np.intp)) + out_arrays.append(rng.astype(np.intp)) + else: # ArrayMap + idx = m.index_array.ravel() + chunk_arrays.append((m.offset + m.stride * idx).astype(np.intp)) + out_arrays.append(out_indices[out_dim]) + return np.ix_(*chunk_arrays), np.ix_(*out_arrays), tuple(drop_axes) + + # Correlated (vindex) sub-transforms carry ArrayMaps with `input_dimension` + # None. They scatter through a single flat index (`out_indices`) into the + # row-major-flattened output buffer; the chunk selection reads a + # (points, residual-slice) block via the raveled coordinate arrays and any + # residual DimensionMap slices. + correlated = any( + isinstance(m, ArrayMap) and m.input_dimension is None for m in sub_transform.output + ) + if correlated: + chunk_sel: list[int | slice | np.ndarray[tuple[int, ...], np.dtype[np.intp]]] = [] + for m in sub_transform.output: + if isinstance(m, ConstantMap): + chunk_sel.append(m.offset) + elif isinstance(m, DimensionMap): + d = m.input_dimension + start = m.offset + m.stride * inclusive_min[d] + stop = m.offset + m.stride * exclusive_max[d] + if m.stride < 0: + start, stop = stop + 1, start + 1 + chunk_sel.append(slice(start, stop, m.stride)) + else: # ArrayMap + idx = m.index_array.reshape(-1) + chunk_sel.append((m.offset + m.stride * idx).astype(np.intp)) + # Chunk resolution always supplies the flat scatter index for a + # correlated transform. Absent one (a bare sub-transform), fall back to an + # identity scatter over the whole flattened output buffer. + # `out_indices` is narrowed to a flat scatter array or None here (the + # per-dimension dict is an orthogonal outer product, handled above). + out_scatter: slice | np.ndarray[Any, np.dtype[np.intp]] + if out_indices is None: + n = 1 + for s in sub_transform.domain.shape: + n *= s + out_scatter = slice(0, n) + else: + out_scatter = out_indices + return tuple(chunk_sel), (out_scatter,), () + + chunk_sel = [] # annotated in the correlated branch above (same function scope) + out_sel: list[slice | np.ndarray[tuple[int, ...], np.dtype[np.intp]]] = [] + + # Single-pass build for the basic / single-orthogonal-array cases. + # ConstantMap dims are dropped (no out_sel entry). + for m in sub_transform.output: + if isinstance(m, ConstantMap): + chunk_sel.append(m.offset) + elif isinstance(m, DimensionMap): + d = m.input_dimension + dim_lo = inclusive_min[d] + dim_hi = exclusive_max[d] + start = m.offset + m.stride * dim_lo + stop = m.offset + m.stride * dim_hi + if m.stride < 0: + start, stop = stop + 1, start + 1 + chunk_sel.append(slice(start, stop, m.stride)) + out_sel.append(slice(dim_lo, dim_hi)) + else: # ArrayMap (orthogonal: full-rank, raveled to its 1-D fancy coords) + idx = m.index_array.reshape(-1) + if m.offset == 0 and m.stride == 1: + chunk_sel.append(idx) + else: + chunk_sel.append((m.offset + m.stride * idx).astype(np.intp)) + # Orthogonal ArrayMap: out_indices holds the surviving positions. + out_sel.append(out_indices if out_indices is not None else slice(0, idx.size)) + + return tuple(chunk_sel), tuple(out_sel), () diff --git a/packages/zarr-indexing/src/zarr_indexing/composition.py b/packages/zarr-indexing/src/zarr_indexing/composition.py new file mode 100644 index 0000000000..8fa71f3e5b --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/composition.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import numpy as np + +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap +from zarr_indexing.transform import IndexTransform + + +def compose(outer: IndexTransform, inner: IndexTransform) -> IndexTransform: + """Compose two IndexTransforms. + + `outer` maps user coords (rank m) to intermediate coords (rank n). + `inner` maps intermediate coords (rank n) to storage coords (rank p). + The result maps user coords (rank m) to storage coords (rank p). + + Precondition: `outer.output_rank == inner.domain.ndim`. + """ + if outer.output_rank != inner.domain.ndim: + raise ValueError( + f"outer output rank ({outer.output_rank}) must match inner input rank " + f"({inner.domain.ndim})" + ) + + result_output = [_compose_single(outer, inner_map) for inner_map in inner.output] + + return IndexTransform(domain=outer.domain, output=tuple(result_output)) + + +def _compose_single(outer: IndexTransform, inner_map: OutputIndexMap) -> OutputIndexMap: + """Compose a single inner output map with the full outer transform.""" + if isinstance(inner_map, ConstantMap): + return ConstantMap(offset=inner_map.offset) + + if isinstance(inner_map, DimensionMap): + return _compose_dimension(outer, inner_map) + + # inner_map: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + return _compose_array(outer, inner_map) + + +def _compose_dimension(outer: IndexTransform, inner_map: DimensionMap) -> OutputIndexMap: + """Compose when inner is a DimensionMap. + + storage = offset_i + stride_i * intermediate[dim_i] + where intermediate[dim_i] = outer.output[dim_i](user_input) + """ + dim_i = inner_map.input_dimension + offset_i = inner_map.offset + stride_i = inner_map.stride + outer_map = outer.output[dim_i] + + if isinstance(outer_map, ConstantMap): + return ConstantMap(offset=offset_i + stride_i * outer_map.offset) + + if isinstance(outer_map, DimensionMap): + return DimensionMap( + input_dimension=outer_map.input_dimension, + offset=offset_i + stride_i * outer_map.offset, + stride=stride_i * outer_map.stride, + ) + + # outer_map: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + # Affine post-composition leaves the index array (and hence its full + # input rank and dependency axes) untouched; carry the orthogonal + # binding through unchanged. + return ArrayMap( + index_array=outer_map.index_array, + offset=offset_i + stride_i * outer_map.offset, + stride=stride_i * outer_map.stride, + input_dimension=outer_map.input_dimension, + ) + + +def _compose_array(outer: IndexTransform, inner_map: ArrayMap) -> OutputIndexMap: + """Compose when inner is an ArrayMap. + + storage = offset_i + stride_i * arr_i[intermediate] + We need to evaluate arr_i at the intermediate coordinates produced by outer. + """ + arr_i = inner_map.index_array + offset_i = inner_map.offset + stride_i = inner_map.stride + + # Check if all outer outputs are constant + all_constant = all(isinstance(m, ConstantMap) for m in outer.output) + + if all_constant: + # Evaluate arr_i at the single constant point + idx = tuple(m.offset for m in outer.output if isinstance(m, ConstantMap)) + value = int(arr_i[idx]) + return ConstantMap(offset=offset_i + stride_i * value) + + # For 1D inner array with a single outer output (simple case) + if arr_i.ndim == 1 and len(outer.output) == 1: + outer_map = outer.output[0] + + if isinstance(outer_map, DimensionMap): + dim_size = outer.domain.shape[outer_map.input_dimension] + user_indices = np.arange(dim_size, dtype=np.intp) + intermediate_vals = outer_map.offset + outer_map.stride * user_indices + new_arr = arr_i[intermediate_vals] + return ArrayMap(index_array=new_arr, offset=offset_i, stride=stride_i) + + if isinstance(outer_map, ArrayMap): + intermediate_vals = outer_map.offset + outer_map.stride * outer_map.index_array + new_arr = arr_i[intermediate_vals] + return ArrayMap(index_array=new_arr, offset=offset_i, stride=stride_i) + + # General multi-dim case: not yet implemented + raise NotImplementedError( + "Composing a multi-dimensional inner array map with non-constant outer maps " + "is not yet supported." + ) diff --git a/packages/zarr-indexing/src/zarr_indexing/domain.py b/packages/zarr-indexing/src/zarr_indexing/domain.py new file mode 100644 index 0000000000..f20d5bf7bd --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/domain.py @@ -0,0 +1,189 @@ +"""Index domains — rectangular regions in N-dimensional integer space. + +An `IndexDomain` represents the set of valid coordinates for an array or +array view. It is the cartesian product of per-dimension integer ranges:: + + IndexDomain(inclusive_min=(2, 5), exclusive_max=(10, 20)) + # represents {(i, j) : 2 <= i < 10, 5 <= j < 20} + +Unlike NumPy, domains can have **non-zero origins**. After slicing +`arr[5:10]`, the result has origin 5 and shape 5 — coordinates 5 through +9 are valid. This follows the TensorStore convention. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True, slots=True) +class IndexDomain: + """A rectangular region in N-dimensional index space. + + The valid coordinates are the integers in + `[inclusive_min[d], exclusive_max[d])` for each dimension `d`. + """ + + inclusive_min: tuple[int, ...] + exclusive_max: tuple[int, ...] + labels: tuple[str, ...] | None = None + # Lazily-memoized shape. Excluded from init/repr/eq/hash: it is derived + # state, not part of the domain's identity. The domain is frozen, so the + # value is computed at most once (see `shape`). `None` is the unset + # sentinel; an empty shape caches as `()`. + _shape: tuple[int, ...] | None = field(default=None, init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + if len(self.inclusive_min) != len(self.exclusive_max): + raise ValueError( + f"inclusive_min and exclusive_max must have the same length. " + f"Got {len(self.inclusive_min)} and {len(self.exclusive_max)}." + ) + for i, (lo, hi) in enumerate(zip(self.inclusive_min, self.exclusive_max, strict=True)): + if lo > hi: + raise ValueError( + f"inclusive_min must be <= exclusive_max for all dimensions. " + f"Dimension {i}: {lo} > {hi}" + ) + if self.labels is not None and len(self.labels) != len(self.inclusive_min): + raise ValueError( + f"labels must have the same length as dimensions. " + f"Got {len(self.labels)} labels for {len(self.inclusive_min)} dimensions." + ) + + @classmethod + def from_shape(cls, shape: tuple[int, ...]) -> IndexDomain: + """Create a domain with origin at zero.""" + return cls( + inclusive_min=(0,) * len(shape), + exclusive_max=shape, + ) + + @property + def ndim(self) -> int: + return len(self.inclusive_min) + + @property + def origin(self) -> tuple[int, ...]: + return self.inclusive_min + + @property + def shape(self) -> tuple[int, ...]: + cached = self._shape + if cached is None: + cached = tuple( + hi - lo for lo, hi in zip(self.inclusive_min, self.exclusive_max, strict=True) + ) + object.__setattr__(self, "_shape", cached) + return cached + + def contains(self, index: tuple[int, ...]) -> bool: + if len(index) != self.ndim: + return False + return all( + lo <= idx < hi + for lo, hi, idx in zip(self.inclusive_min, self.exclusive_max, index, strict=True) + ) + + def contains_domain(self, other: IndexDomain) -> bool: + if other.ndim != self.ndim: + return False + return all( + self_lo <= other_lo and other_hi <= self_hi + for self_lo, self_hi, other_lo, other_hi in zip( + self.inclusive_min, + self.exclusive_max, + other.inclusive_min, + other.exclusive_max, + strict=True, + ) + ) + + def intersect(self, other: IndexDomain) -> IndexDomain | None: + if other.ndim != self.ndim: + raise ValueError( + f"Cannot intersect domains with different ranks: {self.ndim} vs {other.ndim}" + ) + new_min = tuple( + max(a, b) for a, b in zip(self.inclusive_min, other.inclusive_min, strict=True) + ) + new_max = tuple( + min(a, b) for a, b in zip(self.exclusive_max, other.exclusive_max, strict=True) + ) + if any(lo >= hi for lo, hi in zip(new_min, new_max, strict=True)): + return None + return IndexDomain(inclusive_min=new_min, exclusive_max=new_max) + + def translate(self, offset: tuple[int, ...]) -> IndexDomain: + if len(offset) != self.ndim: + raise ValueError( + f"Offset must have same length as domain dimensions. " + f"Domain has {self.ndim} dimensions, offset has {len(offset)}." + ) + new_min = tuple(lo + off for lo, off in zip(self.inclusive_min, offset, strict=True)) + new_max = tuple(hi + off for hi, off in zip(self.exclusive_max, offset, strict=True)) + return IndexDomain(inclusive_min=new_min, exclusive_max=new_max) + + def narrow(self, selection: Any) -> IndexDomain: + """Apply a basic selection and return a narrowed domain. + Indices are absolute coordinates. Integer indices produce length-1 extent. + Strided slices are not supported — use IndexTransform for strides. + """ + normalized = _normalize_selection(selection, self.ndim) + new_inclusive_min: list[int] = [] + new_exclusive_max: list[int] = [] + for dim_idx, (sel, dim_lo, dim_hi) in enumerate( + zip(normalized, self.inclusive_min, self.exclusive_max, strict=True) + ): + if isinstance(sel, int): + if sel < dim_lo or sel >= dim_hi: + raise IndexError( + f"index {sel} is out of bounds for dimension {dim_idx} " + f"with domain [{dim_lo}, {dim_hi})" + ) + new_inclusive_min.append(sel) + new_exclusive_max.append(sel + 1) + else: + start, stop, step = sel.start, sel.stop, sel.step + if step is not None and step != 1: + raise IndexError( + "IndexDomain.narrow only supports step=1 slices. " + f"Got step={step}. Use IndexTransform for strided access." + ) + abs_start = dim_lo if start is None else start + abs_stop = dim_hi if stop is None else stop + abs_start = max(abs_start, dim_lo) + abs_stop = min(abs_stop, dim_hi) + abs_stop = max(abs_stop, abs_start) + new_inclusive_min.append(abs_start) + new_exclusive_max.append(abs_stop) + return IndexDomain( + inclusive_min=tuple(new_inclusive_min), + exclusive_max=tuple(new_exclusive_max), + ) + + +def _normalize_selection(selection: Any, ndim: int) -> tuple[int | slice, ...]: + """Normalize a basic selection to a tuple of ints/slices with length ndim.""" + if not isinstance(selection, tuple): + selection = (selection,) + result: list[int | slice] = [] + ellipsis_seen = False + for sel in selection: + if sel is Ellipsis: + if ellipsis_seen: + raise IndexError("an index can only have a single ellipsis ('...')") + ellipsis_seen = True + num_missing = ndim - (len(selection) - 1) + result.extend([slice(None)] * num_missing) + else: + result.append(sel) + while len(result) < ndim: + result.append(slice(None)) + if len(result) > ndim: + raise IndexError( + f"too many indices for array: array has {ndim} dimensions, " + f"but {len(result)} were indexed" + ) + return tuple(result) diff --git a/packages/zarr-indexing/src/zarr_indexing/errors.py b/packages/zarr-indexing/src/zarr_indexing/errors.py new file mode 100644 index 0000000000..fa2f6fc5d3 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/errors.py @@ -0,0 +1,21 @@ +"""Canonical index-error types raised by the transform algebra. + +These are the authoritative class definitions. `zarr.errors` re-exports the +same objects (`from zarr_indexing.errors import ...`) so that, e.g., +`zarr.errors.BoundsCheckError is zarr_indexing.errors.BoundsCheckError`. +Both subclass the built-in `IndexError`, so existing `except IndexError` (or +`except zarr.errors.BoundsCheckError`) catch sites keep working unchanged. +""" + +from __future__ import annotations + +__all__ = [ + "BoundsCheckError", + "VindexInvalidSelectionError", +] + + +class VindexInvalidSelectionError(IndexError): ... + + +class BoundsCheckError(IndexError): ... diff --git a/packages/zarr-indexing/src/zarr_indexing/grid.py b/packages/zarr-indexing/src/zarr_indexing/grid.py new file mode 100644 index 0000000000..de1dae2dfc --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/grid.py @@ -0,0 +1,25 @@ +"""Structural typing for the chunk-grid surface used by chunk resolution. + +`chunk_resolution` needs only a narrow slice of a chunk grid: the per-dimension +mapping between storage indices and chunk coordinates, passed as one +`DimensionGridLike` per storage dimension. Rather than import zarr's concrete +grid types, we type against this Protocol; zarr's per-dimension grids satisfy +it structurally, so no zarr import is needed here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + import numpy as np + import numpy.typing as npt + + +class DimensionGridLike(Protocol): + """The per-dimension chunk-mapping surface consumed by chunk resolution.""" + + def index_to_chunk(self, idx: int) -> int: ... + def chunk_offset(self, chunk_ix: int) -> int: ... + def chunk_size(self, chunk_ix: int) -> int: ... + def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]: ... diff --git a/packages/zarr-indexing/src/zarr_indexing/json.py b/packages/zarr-indexing/src/zarr_indexing/json.py new file mode 100644 index 0000000000..c95696f309 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/json.py @@ -0,0 +1,325 @@ +"""Lowering between canonical ndsel bodies and in-memory `IndexTransform`s. + +This is the **engine layer**. Where `messages.py` is pure JSON→JSON and imposes +no array constraints, this module converts a *canonical* ndsel transform body +(spec section 4.3, as produced by `zarr_indexing.messages.normalize_ndsel`) +into the numpy-backed `IndexTransform` the chunk engine runs on, and back. + +Two engine constraints live **here and only here**: + +- **Finite bounds.** An `IndexDomain` addresses a finite array, so a canonical + body carrying a `"-inf"`/`"+inf"` bound cannot be lowered; `from_json` raises. +- **Implicit bounds lower by value.** The `[n]`-bracket implicit/explicit flag + is a message-layer concern; the engine keeps only the integer value. + +## The `index_array` wire format (and the degenerate-collapse it documents) + +ndsel and TensorStore both **reject** an output map that carries *both* +`input_dimension` and `index_array`. The in-memory `ArrayMap`, however, records +an `input_dimension` to pin the axis an orthogonal (`oindex`) array varies over. +This module bridges the gap: + +- **On serialize** (`transform_to_canonical`): + 1. An all-singleton `index_array` (size 1) selects a single coordinate + regardless of input, so it is **collapsed to a `constant` map** + `{offset: offset + stride*value}`. The size-1 input dimension stays in the + domain, unconsumed — a valid transform. This makes a length-1 `oindex` + selection round-trip *behaviorally* (an `ArrayMap` becomes a `ConstantMap`) + rather than by object identity. + 2. Non-degenerate `index_array` maps are emitted **without** `input_dimension`. + +- **On load** (`transform_from_canonical`): the in-memory `input_dimension` is + reconstructed from the full-rank array's dependency axes (its non-singleton + axes, see `transform._array_map_dependency_axes`). An array that solely owns a + single non-singleton axis is orthogonal (`input_dimension = that axis`); arrays + that share non-singleton axes, or vary over several, are correlated (`vindex`, + `input_dimension = None`). A single 1-D array over a rank-1 domain is + inherently ambiguous between the two flavours; it reconstructs as orthogonal, + which is behaviorally identical for the single-array case. + +`index_transform_to_json` / `index_transform_from_json` (and the `*_domain_*` +variants) are these canonical converters under their historical names. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Any, Required, TypedDict + +import numpy as np + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.messages import normalize_ndsel +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap +from zarr_indexing.transform import ( + IndexTransform, + _array_map_dependency_axes, # pyright: ignore[reportPrivateUsage] +) + +# `_array_map_dependency_axes` is a leading-underscore helper in `transform.py`, +# but it is deliberately shared with this module (the engine-level JSON <-> +# `IndexTransform` lowering below needs the same dependency-axis logic that +# `transform.py`'s own array-reindexing helpers use). It is not part of the +# package's public API; pyright's `reportPrivateUsage` flags the cross-module +# import anyway. See `chunk_resolution.py`'s `_dimensions` suppression for the +# analogous rationale — whether to promote either symbol out of "private" is +# an open pre-publish API decision, not resolved here. + +# --------------------------------------------------------------------------- +# TypedDict definitions (canonical JSON shapes) +# --------------------------------------------------------------------------- + +# An `index_array` serializes via `ndarray.tolist()`, so it is a nested list of +# ints whose nesting depth equals the array rank. +NestedIntList = list[Any] + +# A canonical *lowered* body carries only finite integer bounds, but the JSON +# shape admits the full ndsel `bound` grammar: an explicit int / sentinel, or a +# one-element implicit `[value]` array. +IndexValueJSON = int | str +BoundJSON = int | str | list[IndexValueJSON] + + +class IndexDomainJSON(TypedDict, total=False): + """Canonical JSON representation of an IndexDomain.""" + + input_inclusive_min: Required[list[BoundJSON]] + input_exclusive_max: Required[list[BoundJSON]] + input_labels: Required[list[str]] + + +class OutputIndexMapJSON(TypedDict, total=False): + """Canonical JSON representation of a single output index map. + + Exactly one of three forms (distinguished by which fields are present): + + - `{"offset": 5}` — constant + - `{"offset": 0, "stride": 1, "input_dimension": 0}` — single_input_dimension + - `{"offset": 0, "stride": 1, "index_array": [...], + "index_array_bounds": ["-inf", "+inf"]}` — index_array + """ + + offset: int + stride: int + input_dimension: int + index_array: NestedIntList + index_array_bounds: list[IndexValueJSON] + + +class IndexTransformJSON(TypedDict, total=False): + """Canonical JSON representation of an IndexTransform (spec section 4.3).""" + + input_rank: Required[int] + input_inclusive_min: Required[list[BoundJSON]] + input_exclusive_max: Required[list[BoundJSON]] + input_labels: Required[list[str]] + output: Required[list[OutputIndexMapJSON]] + + +# --------------------------------------------------------------------------- +# Bound / label lowering (engine constraints) +# --------------------------------------------------------------------------- + + +def _lower_bound(bound: BoundJSON, where: str) -> int: + """Lower a canonical bound to a finite integer, rejecting infinities.""" + value = bound[0] if isinstance(bound, list) else bound + if value == "-inf" or value == "+inf": + raise ValueError( + f"{where} is infinite ({value!r}); an IndexDomain addresses a finite " + f"array and cannot lower an infinite bound" + ) + return int(value) + + +def _lower_labels(labels: list[str]) -> tuple[str, ...] | None: + """All-empty labels collapse to `None` so a label-free domain round-trips.""" + return None if all(label == "" for label in labels) else tuple(labels) + + +def _emit_labels(labels: tuple[str, ...] | None, rank: int) -> list[str]: + """Emit canonical labels: `[""]*rank` when the domain is unlabeled.""" + return [""] * rank if labels is None else list(labels) + + +# --------------------------------------------------------------------------- +# IndexDomain serialization +# --------------------------------------------------------------------------- + + +def index_domain_to_json(domain: IndexDomain) -> IndexDomainJSON: + """Convert an IndexDomain to its canonical JSON representation.""" + return { + "input_inclusive_min": list(domain.inclusive_min), + "input_exclusive_max": list(domain.exclusive_max), + "input_labels": _emit_labels(domain.labels, domain.ndim), + } + + +def index_domain_from_json(data: IndexDomainJSON) -> IndexDomain: + """Construct an IndexDomain from its canonical JSON representation.""" + inclusive_min = tuple( + _lower_bound(b, f"input_inclusive_min[{i}]") + for i, b in enumerate(data["input_inclusive_min"]) + ) + exclusive_max = tuple( + _lower_bound(b, f"input_exclusive_max[{i}]") + for i, b in enumerate(data["input_exclusive_max"]) + ) + labels = _lower_labels(list(data["input_labels"])) + return IndexDomain(inclusive_min=inclusive_min, exclusive_max=exclusive_max, labels=labels) + + +# --------------------------------------------------------------------------- +# OutputIndexMap serialization +# --------------------------------------------------------------------------- + + +def output_index_map_to_json(m: OutputIndexMap) -> OutputIndexMapJSON: + """Convert an output index map to its canonical JSON representation. + + A degenerate all-singleton `ArrayMap` collapses to a `constant` map; a + non-degenerate one is emitted without `input_dimension` (see the module + docstring on the wire format). + """ + if isinstance(m, ConstantMap): + return {"offset": m.offset} + + if isinstance(m, DimensionMap): + return {"offset": m.offset, "stride": m.stride, "input_dimension": m.input_dimension} + + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + if m.index_array.size == 1: + value = int(m.index_array.reshape(-1)[0]) + return {"offset": m.offset + m.stride * value} + return { + "offset": m.offset, + "stride": m.stride, + "index_array": m.index_array.tolist(), + "index_array_bounds": ["-inf", "+inf"], + } + + +def output_index_map_from_json(data: OutputIndexMapJSON) -> OutputIndexMap: + """Construct an output index map from its canonical JSON representation. + + An `index_array` map's `input_dimension` is reconstructed from the array's + dependency axes in isolation (single non-singleton axis → orthogonal). The + transform-level loader classifies globally; use it when several maps may + share axes. + """ + if "index_array" in data: + arr = np.asarray(data["index_array"], dtype=np.intp) + return ArrayMap( + index_array=arr, + offset=data.get("offset", 0), + stride=data.get("stride", 1), + input_dimension=_solo_dependency_axis(arr), + ) + + if "input_dimension" in data: + return DimensionMap( + input_dimension=data["input_dimension"], + offset=data.get("offset", 0), + stride=data.get("stride", 1), + ) + + return ConstantMap(offset=data.get("offset", 0)) + + +def _solo_dependency_axis(arr: np.ndarray[Any, Any]) -> int | None: + """The single axis a lone `index_array` varies over, or `None` if not exactly one.""" + dep = _array_map_dependency_axes(arr) + return dep[0] if len(dep) == 1 else None + + +# --------------------------------------------------------------------------- +# IndexTransform serialization +# --------------------------------------------------------------------------- + + +def transform_to_canonical(transform: IndexTransform) -> IndexTransformJSON: + """Convert an IndexTransform to its canonical ndsel transform body. + + The result is fully explicit (spec section 4.3): `input_rank`, fully written + bounds and labels, and an explicit `output` with `offset`/`stride` present + on every affine and array map. + """ + return { + "input_rank": transform.domain.ndim, + "input_inclusive_min": list(transform.domain.inclusive_min), + "input_exclusive_max": list(transform.domain.exclusive_max), + "input_labels": _emit_labels(transform.domain.labels, transform.domain.ndim), + "output": [output_index_map_to_json(m) for m in transform.output], + } + + +def transform_from_canonical(data: IndexTransformJSON) -> IndexTransform: + """Construct an IndexTransform from a canonical (or canonicalizable) body. + + The body is first run through the message layer (`normalize_ndsel`) so that + omitted fields — identity `output`, default bounds/labels — are filled and + validated, then lowered to the engine representation. `index_array` maps' + `input_dimension` values are reconstructed by global dependency-axis + ownership (see the module docstring). + """ + body = normalize_ndsel({"kind": "transform", **data}) + + inclusive_min = tuple( + _lower_bound(b, f"input_inclusive_min[{i}]") + for i, b in enumerate(body["input_inclusive_min"]) + ) + exclusive_max = tuple( + _lower_bound(b, f"input_exclusive_max[{i}]") + for i, b in enumerate(body["input_exclusive_max"]) + ) + domain = IndexDomain( + inclusive_min=inclusive_min, + exclusive_max=exclusive_max, + labels=_lower_labels(body["input_labels"]), + ) + + output_raw: list[dict[str, Any]] = body["output"] + + # Classify index_array maps globally: an axis owned by exactly one array map + # (and the map's sole non-singleton axis) marks that map orthogonal; shared + # or multiple non-singleton axes mark the maps correlated (vindex). + array_axes: dict[int, tuple[int, ...]] = {} + axis_owners: Counter[int] = Counter() + for i, om in enumerate(output_raw): + if "index_array" in om: + arr = np.asarray(om["index_array"], dtype=np.intp) + dep = _array_map_dependency_axes(arr) + array_axes[i] = dep + axis_owners.update(dep) + + output: list[OutputIndexMap] = [] + for i, om in enumerate(output_raw): + if "index_array" in om: + dep = array_axes[i] + input_dim = dep[0] if len(dep) == 1 and axis_owners[dep[0]] == 1 else None + output.append( + ArrayMap( + index_array=np.asarray(om["index_array"], dtype=np.intp), + offset=om.get("offset", 0), + stride=om.get("stride", 1), + input_dimension=input_dim, + ) + ) + elif "input_dimension" in om: + output.append( + DimensionMap( + input_dimension=om["input_dimension"], + offset=om.get("offset", 0), + stride=om.get("stride", 1), + ) + ) + else: + output.append(ConstantMap(offset=om.get("offset", 0))) + + return IndexTransform(domain=domain, output=tuple(output)) + + +# Historical names, now pointing at the canonical converters. +index_transform_to_json = transform_to_canonical +index_transform_from_json = transform_from_canonical diff --git a/packages/zarr-indexing/src/zarr_indexing/messages.py b/packages/zarr-indexing/src/zarr_indexing/messages.py new file mode 100644 index 0000000000..0ee6cb97ab --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/messages.py @@ -0,0 +1,657 @@ +"""The ndsel message layer — pure JSON in, canonical JSON out. + +This module implements the [ndsel](https://github.com/d-v-b/ndsel) draft wire +format: a JSON-serializable representation of NumPy-style n-dimensional +selections that adapts TensorStore's `IndexTransform` model. It is a **pure +JSON→JSON** layer: it depends on nothing but the standard library, imposes no +engine (numpy/array) constraints, and never rounds, clamps, or drops +information. Engine constraints (finite bounds, in-memory `IndexTransform` +construction) live one layer up, in `json.py`. + +Two entry points: + +- `parse_ndsel(obj)` — structurally validate an ndsel message of any of the + five kinds (`point`/`box`/`slice`/`points`/`transform`), returning it + unchanged. Raises `NdselError` (carrying a spec reason code) on any defect. +- `normalize_ndsel(obj)` — desugar and canonicalize a message to the single + deterministic **canonical transform body** of the spec (section 4.3): a bare + `IndexTransform` JSON body, without the `kind` discriminator. `normalize` is + idempotent when its output is re-tagged with `kind: "transform"`. + +The canonical body is, field-for-field, a TensorStore `IndexTransform` (minus +`kind`), so a normalized `transform` loads directly into TensorStore once +`kind` is stripped. + +Value rules enforced here: every integer is a 64-bit signed value; JSON +booleans are **not** integers (Python's `isinstance(True, int)` is guarded +against explicitly); the `"-inf"`/`"+inf"` sentinels are legal only in bound +positions; an implicit bound is the one-element `[n]`-bracket form, and its +implicit/explicit flag is preserved through normalization. +""" + +from __future__ import annotations + +from typing import Any + +__all__ = [ + "NdselError", + "normalize_ndsel", + "parse_ndsel", +] + +# --------------------------------------------------------------------------- +# Error taxonomy +# --------------------------------------------------------------------------- + +#: The complete set of ndsel reason codes (spec section 6). +REASON_CODES = frozenset( + { + "invalid_json", + "unknown_kind", + "unknown_field", + "multiple_upper_bounds", + "bounds_out_of_order", + "output_map_conflict", + "rank_mismatch", + "step_zero", + "negative_step_unsupported", + } +) + + +class NdselError(ValueError): + """An ndsel message failed validation. + + Carries the spec `reason` code (one of `REASON_CODES`) so callers and the + conformance harness can assert on it directly, plus a human-readable + `detail`. + """ + + def __init__(self, reason: str, detail: str = "") -> None: + self.reason = reason + self.detail = detail + super().__init__(f"{reason}: {detail}" if detail else reason) + + +# --------------------------------------------------------------------------- +# 64-bit signed integer range (spec section 3.5) +# --------------------------------------------------------------------------- + +_I64_MIN = -(2**63) +_I64_MAX = 2**63 - 1 + +_KNOWN_KINDS = frozenset({"point", "box", "slice", "points", "transform"}) + +# The two upper-bound spellings, keyed by message prefix. Only one of the three +# per group may appear (spec section 4.1 / 5.2). +_BOX_UPPER = ("exclusive_max", "inclusive_max", "shape") +_TRANSFORM_UPPER = ("input_exclusive_max", "input_inclusive_max", "input_shape") + +_OUTPUT_MAP_FIELDS = frozenset( + {"offset", "stride", "input_dimension", "index_array", "index_array_bounds"} +) + + +# --------------------------------------------------------------------------- +# Leaf value validators +# --------------------------------------------------------------------------- + + +def _is_int(value: Any) -> bool: + """True iff `value` is a JSON integer — an `int` that is not a `bool`. + + JSON has no boolean-as-integer: `True`/`False` are rejected even though + Python makes `bool` a subclass of `int` (spec section 3.6). + """ + return isinstance(value, int) and not isinstance(value, bool) + + +def _check_int(value: Any, where: str) -> int: + """Validate a plain-integer position: an in-range i64, never a sentinel.""" + if not _is_int(value): + raise NdselError("invalid_json", f"{where} must be an integer, got {value!r}") + if value < _I64_MIN or value > _I64_MAX: + raise NdselError("invalid_json", f"{where} is outside the 64-bit signed range: {value}") + return int(value) + + +def _is_sentinel(value: Any) -> bool: + return value in ("-inf", "+inf") + + +def _check_index_value(value: Any, where: str) -> int | str: + """Validate an `index-value`: an in-range i64 or a `"-inf"`/`"+inf"` sentinel.""" + if _is_sentinel(value): + return str(value) + return _check_int(value, where) + + +def _check_bound(value: Any, where: str) -> int | str | list[int | str]: + """Validate a `bound`: an explicit `index-value`, or a one-element implicit `[index-value]`.""" + if isinstance(value, list): + if len(value) != 1: + raise NdselError( + "invalid_json", + f"{where} implicit bound must be a one-element array, got {value!r}", + ) + return [_check_index_value(value[0], where)] + return _check_index_value(value, where) + + +def _check_int_list(value: Any, where: str) -> list[int]: + if not isinstance(value, list): + raise NdselError("invalid_json", f"{where} must be an array, got {value!r}") + return [_check_int(v, f"{where}[{i}]") for i, v in enumerate(value)] + + +def _check_bound_list(value: Any, where: str) -> list[Any]: + if not isinstance(value, list): + raise NdselError("invalid_json", f"{where} must be an array, got {value!r}") + return [_check_bound(v, f"{where}[{i}]") for i, v in enumerate(value)] + + +def _check_label_list(value: Any, where: str) -> list[str]: + if not isinstance(value, list): + raise NdselError("invalid_json", f"{where} must be an array, got {value!r}") + for i, v in enumerate(value): + if not isinstance(v, str): + raise NdselError("invalid_json", f"{where}[{i}] must be a string, got {v!r}") + return list(value) + + +# --------------------------------------------------------------------------- +# Extended-integer order for bounds (spec section 4.1) +# --------------------------------------------------------------------------- + + +def _bound_value(bound: int | str | list[int | str]) -> int | str: + """The underlying `index-value` of a bound, dropping the implicit bracket.""" + return bound[0] if isinstance(bound, list) else bound + + +def _bound_is_implicit(bound: int | str | list[int | str]) -> bool: + return isinstance(bound, list) + + +def _ext_key(value: int | str) -> tuple[int, int]: + """A sort key giving the extended-integer order `-inf < n < +inf` exactly. + + Uses an integer tier plus the value, so no float rounding of near-`2**63` + integers can misorder the `inclusive_min <= exclusive_max` check. + """ + if value == "-inf": + return (0, 0) + if value == "+inf": + return (2, 0) + assert isinstance(value, int) + return (1, value) + + +def _rewrap(value: int | str, *, implicit: bool) -> int | str | list[int | str]: + return [value] if implicit else value + + +# --------------------------------------------------------------------------- +# Message-level helpers +# --------------------------------------------------------------------------- + + +def _require_object(obj: Any) -> dict[str, Any]: + if not isinstance(obj, dict): + raise NdselError("invalid_json", f"message must be a JSON object, got {type(obj).__name__}") + return obj + + +def _message_kind(obj: dict[str, Any]) -> str: + kind = obj.get("kind") + if not isinstance(kind, str): + raise NdselError("invalid_json", "message must have a string 'kind' field") + if kind not in _KNOWN_KINDS: + raise NdselError("unknown_kind", f"unknown kind {kind!r}") + return kind + + +def _check_membership(obj: dict[str, Any], allowed: frozenset[str], what: str) -> None: + """Strict membership (spec section 3.7): reject any undefined member.""" + for key in obj: + if key not in allowed: + raise NdselError("unknown_field", f"{what} has undefined member {key!r}") + + +def _single_upper_bound(obj: dict[str, Any], fields: tuple[str, str, str]) -> str | None: + present = [f for f in fields if f in obj] + if len(present) > 1: + raise NdselError( + "multiple_upper_bounds", + f"at most one of {fields} may be present; got {present}", + ) + return present[0] if present else None + + +def _resolve_upper_bound( + upper_field: str | None, + upper_raw: list[Any] | None, + inclusive_min: list[Any], + rank: int, + *, + kind_of: str, +) -> list[int | str | list[int | str]]: + """Produce `exclusive_max` from whichever upper-bound spelling was supplied. + + - `exclusive_max`/`input_exclusive_max` → used directly. + - `inclusive_max`/`input_inclusive_max` → each element `+1`. + - `shape`/`input_shape` → `inclusive_min + shape` per element. + - none → an **implicit `+inf`** in every dimension. + + The implicit/explicit bracket travels with the extent-bearing field (the + upper bound, or `shape`), matching the spec's `[n]`-bracket convention. + """ + if upper_field is None: + return [["+inf"] for _ in range(rank)] + + assert upper_raw is not None + if kind_of == "exclusive": + return list(upper_raw) + + result: list[int | str | list[int | str]] = [] + for k in range(rank): + raw = upper_raw[k] + implicit = _bound_is_implicit(raw) + value = _bound_value(raw) + if kind_of == "inclusive": + new = _inclusive_to_exclusive(value) + else: # shape + new = _shape_to_exclusive(_bound_value(inclusive_min[k]), value) + result.append(_rewrap(new, implicit=implicit)) + return result + + +def _inclusive_to_exclusive(value: int | str) -> int | str: + if value == "+inf" or value == "-inf": + return value + assert isinstance(value, int) + return value + 1 + + +def _shape_to_exclusive(min_value: int | str, shape_value: int | str) -> int | str: + if shape_value == "+inf" or min_value == "+inf": + return "+inf" + if min_value == "-inf": + return "-inf" + assert isinstance(min_value, int) + assert isinstance(shape_value, int) + return min_value + shape_value + + +def _validate_domain(inclusive_min: list[Any], exclusive_max: list[Any], *, prefix: str) -> None: + """Every dimension must satisfy `inclusive_min <= exclusive_max` (empty is valid).""" + for k, (lo, hi) in enumerate(zip(inclusive_min, exclusive_max, strict=True)): + if _ext_key(_bound_value(lo)) > _ext_key(_bound_value(hi)): + raise NdselError( + "bounds_out_of_order", + f"{prefix}[{k}]: inclusive_min {_bound_value(lo)!r} > " + f"exclusive_max {_bound_value(hi)!r}", + ) + + +def _identity_output(rank: int) -> list[dict[str, Any]]: + return [{"offset": 0, "stride": 1, "input_dimension": k} for k in range(rank)] + + +# --------------------------------------------------------------------------- +# Per-kind desugaring +# --------------------------------------------------------------------------- + + +def _normalize_point(obj: dict[str, Any]) -> dict[str, Any]: + _check_membership(obj, frozenset({"kind", "coords"}), "point") + if "coords" not in obj: + raise NdselError("invalid_json", "point requires 'coords'") + coords = _check_int_list(obj["coords"], "coords") + return { + "input_rank": 0, + "input_inclusive_min": [], + "input_exclusive_max": [], + "input_labels": [], + "output": [{"offset": c} for c in coords], + } + + +def _infer_rank( + obj: dict[str, Any], + named_lengths: list[tuple[str, int]], + *, + declared: int | None, +) -> int: + """Reconcile a declared rank (if any) with every present array's length.""" + rank = declared + for name, length in named_lengths: + if rank is None: + rank = length + elif rank != length: + raise NdselError( + "rank_mismatch", + f"{name} has length {length}, inconsistent with rank {rank}", + ) + return rank if rank is not None else 0 + + +def _normalize_box(obj: dict[str, Any]) -> dict[str, Any]: + allowed = frozenset( + {"kind", "inclusive_min", "exclusive_max", "inclusive_max", "shape", "labels"} + ) + _check_membership(obj, allowed, "box") + + inclusive_min_raw = ( + _check_bound_list(obj["inclusive_min"], "inclusive_min") if "inclusive_min" in obj else None + ) + upper_field = _single_upper_bound(obj, _BOX_UPPER) + upper_raw = _check_bound_list(obj[upper_field], upper_field) if upper_field else None + labels_raw = _check_label_list(obj["labels"], "labels") if "labels" in obj else None + + named_lengths: list[tuple[str, int]] = [] + if inclusive_min_raw is not None: + named_lengths.append(("inclusive_min", len(inclusive_min_raw))) + if upper_raw is not None: + named_lengths.append((upper_field or "", len(upper_raw))) + if labels_raw is not None: + named_lengths.append(("labels", len(labels_raw))) + rank = _infer_rank(obj, named_lengths, declared=None) + + inclusive_min = inclusive_min_raw if inclusive_min_raw is not None else [0] * rank + exclusive_max = _resolve_upper_bound( + upper_field, upper_raw, inclusive_min, rank, kind_of=_upper_kind(upper_field, _BOX_UPPER) + ) + labels = labels_raw if labels_raw is not None else [""] * rank + _validate_domain(inclusive_min, exclusive_max, prefix="box") + + return { + "input_rank": rank, + "input_inclusive_min": inclusive_min, + "input_exclusive_max": exclusive_max, + "input_labels": labels, + "output": _identity_output(rank), + } + + +def _upper_kind(upper_field: str | None, fields: tuple[str, str, str]) -> str: + if upper_field is None or upper_field == fields[0]: + return "exclusive" + if upper_field == fields[1]: + return "inclusive" + return "shape" + + +def _normalize_slice(obj: dict[str, Any]) -> dict[str, Any]: + allowed = frozenset({"kind", "start", "stop", "step", "labels"}) + _check_membership(obj, allowed, "slice") + if "start" not in obj: + raise NdselError("invalid_json", "slice requires 'start'") + if "stop" not in obj: + raise NdselError("invalid_json", "slice requires 'stop'") + start = _check_int_list(obj["start"], "start") + stop = _check_int_list(obj["stop"], "stop") + step = _check_int_list(obj["step"], "step") if "step" in obj else [1] * len(start) + labels_raw = _check_label_list(obj["labels"], "labels") if "labels" in obj else None + + n = len(start) + for name, arr in (("stop", stop), ("step", step)): + if len(arr) != n: + raise NdselError( + "rank_mismatch", f"{name} has length {len(arr)}, expected {n} (from start)" + ) + if labels_raw is not None and len(labels_raw) != n: + raise NdselError( + "rank_mismatch", f"labels has length {len(labels_raw)}, expected {n} (from start)" + ) + + for k, s in enumerate(step): + if s == 0: + raise NdselError("step_zero", f"step[{k}] is zero") + if s < 0: + raise NdselError("negative_step_unsupported", f"step[{k}] is negative ({s})") + + inclusive_min: list[Any] = [] + exclusive_max: list[Any] = [] + output: list[dict[str, Any]] = [] + for k in range(n): + a, b, s = start[k], stop[k], step[k] + m = max(0, -(-(b - a) // s)) # ceil((b - a) / s) + o = _trunc_div(a, s) # trunc(a / s), toward zero + offset = a - s * o # lattice phase, in (-s, s) + inclusive_min.append(o) + exclusive_max.append(o + m) + output.append({"offset": offset, "stride": s, "input_dimension": k}) + + labels = labels_raw if labels_raw is not None else [""] * n + return { + "input_rank": n, + "input_inclusive_min": inclusive_min, + "input_exclusive_max": exclusive_max, + "input_labels": labels, + "output": output, + } + + +def _normalize_points(obj: dict[str, Any]) -> dict[str, Any]: + _check_membership(obj, frozenset({"kind", "coords"}), "points") + if "coords" not in obj: + raise NdselError("invalid_json", "points requires 'coords'") + coords = obj["coords"] + if not isinstance(coords, list): + raise NdselError("invalid_json", f"points coords must be an array, got {coords!r}") + + rows: list[list[int]] = [] + n: int | None = None + for i, row in enumerate(coords): + if not isinstance(row, list): + raise NdselError("invalid_json", f"points coords[{i}] must be an array, got {row!r}") + row_ints = [_check_int(v, f"coords[{i}][{j}]") for j, v in enumerate(row)] + if n is None: + n = len(row_ints) + elif len(row_ints) != n: + raise NdselError( + "rank_mismatch", + f"points coords[{i}] has length {len(row_ints)}, expected {n} (ragged)", + ) + rows.append(row_ints) + + m = len(rows) + n = n if n is not None else 0 + output = [ + { + "offset": 0, + "stride": 1, + "index_array": [rows[i][k] for i in range(m)], + "index_array_bounds": ["-inf", "+inf"], + } + for k in range(n) + ] + return { + "input_rank": 1, + "input_inclusive_min": [0], + "input_exclusive_max": [m], + "input_labels": [""], + "output": output, + } + + +def _normalize_output_map(raw: Any, where: str) -> dict[str, Any]: + if not isinstance(raw, dict): + raise NdselError("invalid_json", f"{where} must be a JSON object, got {raw!r}") + _check_membership(raw, _OUTPUT_MAP_FIELDS, where) + + has_index_array = "index_array" in raw + has_input_dim = "input_dimension" in raw + if has_index_array and has_input_dim: + raise NdselError( + "output_map_conflict", + f"{where} carries both 'input_dimension' and 'index_array'", + ) + + offset = _check_int(raw["offset"], f"{where}.offset") if "offset" in raw else 0 + + if has_index_array: + stride = _check_int(raw["stride"], f"{where}.stride") if "stride" in raw else 1 + bounds = ( + _check_index_array_bounds(raw["index_array_bounds"], where) + if "index_array_bounds" in raw + else ["-inf", "+inf"] + ) + # index_array is carried verbatim (spec section 7 defers shape validation). + return { + "offset": offset, + "stride": stride, + "index_array": raw["index_array"], + "index_array_bounds": bounds, + } + + if has_input_dim: + input_dim = _check_int(raw["input_dimension"], f"{where}.input_dimension") + if input_dim < 0: + raise NdselError( + "invalid_json", f"{where}.input_dimension must be >= 0, got {input_dim}" + ) + stride = _check_int(raw["stride"], f"{where}.stride") if "stride" in raw else 1 + return {"offset": offset, "stride": stride, "input_dimension": input_dim} + + # Constant map: only offset survives. A stray `stride`/`index_array_bounds` + # is schema-valid (the output-map schema permits those members on any map), + # so it is silently dropped rather than rejected — a constant carries only + # `offset` in canonical form (spec section 4.3). + return {"offset": offset} + + +def _check_index_array_bounds(value: Any, where: str) -> list[int | str]: + if not isinstance(value, list) or len(value) != 2: + raise NdselError( + "invalid_json", + f"{where}.index_array_bounds must be a two-element array, got {value!r}", + ) + return [ + _check_index_value(value[0], f"{where}.index_array_bounds[0]"), + _check_index_value(value[1], f"{where}.index_array_bounds[1]"), + ] + + +def _normalize_transform(obj: dict[str, Any]) -> dict[str, Any]: + allowed = frozenset( + { + "kind", + "input_rank", + "input_inclusive_min", + "input_exclusive_max", + "input_inclusive_max", + "input_shape", + "input_labels", + "output", + } + ) + _check_membership(obj, allowed, "transform") + + declared_rank: int | None = None + if "input_rank" in obj: + declared_rank = _check_int(obj["input_rank"], "input_rank") + if declared_rank < 0: + raise NdselError("invalid_json", f"input_rank must be >= 0, got {declared_rank}") + + inclusive_min_raw = ( + _check_bound_list(obj["input_inclusive_min"], "input_inclusive_min") + if "input_inclusive_min" in obj + else None + ) + upper_field = _single_upper_bound(obj, _TRANSFORM_UPPER) + upper_raw = _check_bound_list(obj[upper_field], upper_field) if upper_field else None + labels_raw = ( + _check_label_list(obj["input_labels"], "input_labels") if "input_labels" in obj else None + ) + + named_lengths: list[tuple[str, int]] = [] + if inclusive_min_raw is not None: + named_lengths.append(("input_inclusive_min", len(inclusive_min_raw))) + if upper_raw is not None: + named_lengths.append((upper_field or "", len(upper_raw))) + if labels_raw is not None: + named_lengths.append(("input_labels", len(labels_raw))) + rank = _infer_rank(obj, named_lengths, declared=declared_rank) + + inclusive_min = inclusive_min_raw if inclusive_min_raw is not None else [0] * rank + exclusive_max = _resolve_upper_bound( + upper_field, + upper_raw, + inclusive_min, + rank, + kind_of=_upper_kind(upper_field, _TRANSFORM_UPPER), + ) + labels = labels_raw if labels_raw is not None else [""] * rank + _validate_domain(inclusive_min, exclusive_max, prefix="input") + + if "output" in obj: + if not isinstance(obj["output"], list): + raise NdselError("invalid_json", f"output must be an array, got {obj['output']!r}") + output = [_normalize_output_map(m, f"output[{i}]") for i, m in enumerate(obj["output"])] + else: + output = _identity_output(rank) + + return { + "input_rank": rank, + "input_inclusive_min": inclusive_min, + "input_exclusive_max": exclusive_max, + "input_labels": labels, + "output": output, + } + + +_NORMALIZERS = { + "point": _normalize_point, + "box": _normalize_box, + "slice": _normalize_slice, + "points": _normalize_points, + "transform": _normalize_transform, +} + + +# --------------------------------------------------------------------------- +# trunc division (spec section 5.3 correction, matches _trunc_div in transform.py) +# --------------------------------------------------------------------------- + + +def _trunc_div(a: int, b: int) -> int: + """Integer division rounded toward zero (C semantics).""" + q = a // b + if q < 0 and q * b != a: + q += 1 + return q + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + + +def normalize_ndsel(obj: Any) -> dict[str, Any]: + """Desugar and canonicalize an ndsel message to its canonical transform body. + + Accepts any of the five message kinds and returns the bare canonical + `IndexTransform` body of spec section 4.3 — no `kind` field. Raises + `NdselError` (carrying a reason code) for any invalid input. + """ + message = _require_object(obj) + kind = _message_kind(message) + return _NORMALIZERS[kind](message) + + +def parse_ndsel(obj: Any) -> dict[str, Any]: + """Structurally validate an ndsel message, returning it unchanged. + + A lighter gate than `normalize_ndsel`: it confirms the message is a + well-formed ndsel message of a recognized kind (correct field membership, + JSON types, upper-bound exclusivity, domain ordering, step signs) and + raises `NdselError` otherwise, but does not desugar it. Useful for + validating a message you intend to keep in its compact shorthand form. + """ + message = _require_object(obj) + _message_kind(message) + # Validation and desugaring share one pass; run it and discard the body. + normalize_ndsel(message) + return message diff --git a/packages/zarr-indexing/src/zarr_indexing/output_map.py b/packages/zarr-indexing/src/zarr_indexing/output_map.py new file mode 100644 index 0000000000..581229bd22 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/output_map.py @@ -0,0 +1,105 @@ +"""Output index maps — three representations of a set of integer coordinates. + +An output index map describes, for one dimension of storage, which coordinates +an array access will touch. Conceptually it is a **set of integers**. Three +representations cover the cases that arise in practice: + +- `ConstantMap(offset=5)` — a singleton set: `{5}` +- `DimensionMap(input_dimension=0, offset=3, stride=2)` over input `[0, 5)` + — an arithmetic progression: `{3, 5, 7, 9, 11}` +- `ArrayMap(index_array=[1, 5, 9])` — an explicit enumeration: `{1, 5, 9}` + +Every output map supports two set-theoretic operations (defined on +`IndexTransform`, which provides the input domain context these maps lack): + +- **intersect** — restrict to coordinates within a range (e.g., a chunk). + `{3, 5, 7, 9, 11} ∩ [4, 8) = {5, 7}` +- **translate** — shift every coordinate by a constant (e.g., make chunk-local). + `{5, 7} - 4 = {1, 3}` + +These two operations are the foundation of chunk resolution: for each chunk, +intersect the map with the chunk's range, then translate to chunk-local +coordinates. + +The three types exist because they trade off generality for efficiency: + +- `ConstantMap`: O(1) storage, O(1) intersection +- `DimensionMap`: O(1) storage, O(1) intersection (analytical) +- `ArrayMap`: O(n) storage, O(n) intersection (must scan the array) + +Collapsing everything to `ArrayMap` would be correct but wasteful — a +billion-element slice would materialize a billion coordinates just to group +them by chunk, when `DimensionMap` does it with three integers. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import numpy as np + import numpy.typing as npt + + +@dataclass(frozen=True, slots=True) +class ConstantMap: + """A singleton set: one storage coordinate. + + Represents `{offset}`. Arises from integer indexing (e.g., `arr[5]` + fixes one dimension to coordinate 5). + """ + + offset: int = 0 + + +@dataclass(frozen=True, slots=True) +class DimensionMap: + """An arithmetic progression of storage coordinates. + + Represents `{offset + stride * i : i in input_range}`, where the input + range comes from the enclosing `IndexTransform`'s domain. Arises from + slice indexing (e.g., `arr[2:10:3]` gives offset=2, stride=3). + """ + + input_dimension: int + offset: int = 0 + stride: int = 1 + + +@dataclass(frozen=True, slots=True) +class ArrayMap: + """An explicit enumeration of storage coordinates. + + Represents `{offset + stride * index_array[i] : i in input_range}`. + Arises from fancy indexing (e.g., `arr[[1, 5, 9]]` or boolean masks). + + Freshly constructed maps are normalized to the **full input rank** of their + enclosing transform: `index_array` has the enclosing domain's rank, sized + fully on the axes it varies over and singleton (size 1) elsewhere. The + dependency axes are therefore derivable from the shape (see + `transform._array_map_dependency_axes`), which distinguishes the two flavours + of multi-array fancy indexing: + + - **orthogonal** (`oindex`): each array varies along a single, *distinct* + axis (all others singleton); the result is their outer product. + - **vectorized** (`vindex`): the arrays are correlated and share the same + non-singleton (broadcast) axes; the result is a pointwise scatter. + + `input_dimension` records the single axis an orthogonal array varies over + (`None` for vectorized), binding it the way `DimensionMap` is bound. It is + usually redundant with the shape-derived classifier, but stays authoritative + for the shapes the classifier cannot distinguish: a length-1 orthogonal + selection normalizes to an all-singleton array (no non-singleton axis), and + length-1 vectorized arrays are equally degenerate. `None` therefore marks a + map as correlated, and an integer pins the dependency axis of a degenerate + orthogonal map (see `transform._array_map_dependent_axis`). + """ + + index_array: npt.NDArray[np.intp] + offset: int = 0 + stride: int = 1 + input_dimension: int | None = None + + +OutputIndexMap = ConstantMap | DimensionMap | ArrayMap diff --git a/packages/zarr-indexing/src/zarr_indexing/py.typed b/packages/zarr-indexing/src/zarr_indexing/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/zarr-indexing/src/zarr_indexing/transform.py b/packages/zarr-indexing/src/zarr_indexing/transform.py new file mode 100644 index 0000000000..e1a3898b1d --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/transform.py @@ -0,0 +1,1311 @@ +"""Index transforms — composable, lazy coordinate mappings. + +An `IndexTransform` pairs an **input domain** (the coordinates a user sees) +with a tuple of **output maps** (the storage coordinates those inputs map to). +One output map per storage dimension. See `output_map.py` for the three +output map types. + +Key operations: + +- **Indexing** (`transform[2:8]`, `.oindex[idx]`, `.vindex[idx]`) — + produces a new transform with a narrower input domain and adjusted output + maps. No I/O occurs. This is how lazy slicing works. + +- **intersect(output_domain)** — restrict to storage coordinates within a + region. This is chunk resolution: "which of my coordinates fall in this + chunk?" + +- **translate(shift)** — shift all output coordinates. This makes coordinates + chunk-local: "express my coordinates relative to the chunk origin." + +- **compose(outer, inner)** — chain two transforms. See `composition.py`. + +The transform is the atomic unit that connects user-facing indexing to +chunk-level I/O. Every `Array` holds a transform (identity by default). +`Array.lazy[...]` composes a new transform lazily. Reading resolves the +transform against the chunk grid via intersect + translate. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Literal, cast + +import numpy as np + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.errors import BoundsCheckError, VindexInvalidSelectionError +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap + + +@dataclass(frozen=True, slots=True) +class IndexTransform: + """A composable mapping from input coordinates to storage coordinates. + + An `IndexTransform` has: + + - `domain`: an `IndexDomain` describing the valid input coordinates + (the user-facing shape, possibly with non-zero origin). + - `output`: a tuple of output maps (one per storage dimension), each + describing which storage coordinates the inputs touch. + + For a freshly opened array, the transform is the identity: input + coordinate `i` maps to storage coordinate `i`. Indexing operations + compose new transforms without I/O. + """ + + domain: IndexDomain + output: tuple[OutputIndexMap, ...] + + def __post_init__(self) -> None: + for i, m in enumerate(self.output): + if isinstance(m, DimensionMap): + if m.input_dimension < 0 or m.input_dimension >= self.domain.ndim: + raise ValueError( + f"output[{i}].input_dimension = {m.input_dimension} " + f"is out of range for input rank {self.domain.ndim}" + ) + elif isinstance(m, ArrayMap) and m.index_array.ndim > self.domain.ndim: + # ArrayMap index arrays produced by indexing and chunk resolution + # are normalized to the full input rank (an axis the array varies + # over is full-sized, every other axis a singleton). A rank + # *exceeding* the domain is always a bug. A rank *below* it is + # tolerated: TensorStore-format JSON (external input) may supply a + # lower-rank index array that broadcasts against the input domain, + # and `_array_map_dependency_axes` treats any missing leading axes + # as singleton dependencies. + raise ValueError( + f"output[{i}].index_array has {m.index_array.ndim} dims " + f"but input domain has {self.domain.ndim} dims" + ) + + @property + def input_rank(self) -> int: + return self.domain.ndim + + @property + def output_rank(self) -> int: + return len(self.output) + + @classmethod + def identity(cls, domain: IndexDomain) -> IndexTransform: + output = tuple(DimensionMap(input_dimension=i) for i in range(domain.ndim)) + return cls(domain=domain, output=output) + + @classmethod + def from_shape(cls, shape: tuple[int, ...]) -> IndexTransform: + return cls.identity(IndexDomain.from_shape(shape)) + + @property + def selection_repr(self) -> str: + """Compact domain string, e.g. `'{ [2, 8), [0, 10) }'`. + + Follows TensorStore's IndexDomain notation: each dimension shown + as `[inclusive_min, exclusive_max)` with stride annotation if not 1. + Constant (integer-indexed) dimensions show as a single value. + Array-indexed dimensions show the set of selected coordinates. + """ + parts: list[str] = [] + for m in self.output: + if isinstance(m, ConstantMap): + parts.append(str(m.offset)) + elif isinstance(m, DimensionMap): + d = m.input_dimension + lo = self.domain.inclusive_min[d] + hi = self.domain.exclusive_max[d] + start = m.offset + m.stride * lo + stop = m.offset + m.stride * hi + if m.stride == 1: + parts.append(f"[{start}, {stop})") + else: + parts.append(f"[{start}, {stop}) step {m.stride}") + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + storage = m.offset + m.stride * m.index_array + n = int(storage.size) # .size, not len(): index_array may be 0-d + if n <= 5: + vals = ", ".join(str(int(v)) for v in storage.ravel()) + parts.append("{" + vals + "}") + else: + parts.append("{" + f"array({n})" + "}") + return "{ " + ", ".join(parts) + " }" + + def __repr__(self) -> str: + maps: list[str] = [] + for i, m in enumerate(self.output): + if isinstance(m, ConstantMap): + maps.append(f"out[{i}] = {m.offset}") + elif isinstance(m, DimensionMap): + maps.append(f"out[{i}] = {m.offset} + {m.stride} * in[{m.input_dimension}]") + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + maps.append(f"out[{i}] = {m.offset} + {m.stride} * arr{m.index_array.shape}[in]") + maps_str = ", ".join(maps) + return f"IndexTransform(domain={self.domain}, {maps_str})" + + def intersect( + self, output_domain: IndexDomain + ) -> ( + tuple[ + IndexTransform, + dict[int, np.ndarray[Any, np.dtype[np.intp]]] + | np.ndarray[Any, np.dtype[np.intp]] + | None, + ] + | None + ): + """Restrict this transform to storage coordinates within output_domain. + + Returns `(restricted_transform, out_indices)` or None if empty. + + `out_indices` carries the surviving output positions: `None` when all + positions survive (ConstantMap/DimensionMap only), a single integer array + for one ArrayMap (or correlated/vectorized ArrayMaps), or a dict keyed by + output dimension for >= 2 orthogonal ArrayMaps (an outer product). + """ + return _intersect(self, output_domain) + + def translate(self, shift: tuple[int, ...]) -> IndexTransform: + """Shift all output coordinates by `shift`.""" + if len(shift) != self.output_rank: + raise ValueError(f"shift must have length {self.output_rank}, got {len(shift)}") + new_output: list[OutputIndexMap] = [] + for m, s in zip(self.output, shift, strict=True): + if isinstance(m, ConstantMap): + new_output.append(ConstantMap(offset=m.offset + s)) + elif isinstance(m, DimensionMap): + new_output.append( + DimensionMap( + input_dimension=m.input_dimension, + offset=m.offset + s, + stride=m.stride, + ) + ) + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + new_output.append( + ArrayMap( + index_array=m.index_array, + offset=m.offset + s, + stride=m.stride, + input_dimension=m.input_dimension, + ) + ) + return IndexTransform(domain=self.domain, output=tuple(new_output)) + + def __getitem__(self, selection: Any) -> IndexTransform: + return _apply_basic_indexing(self, selection) + + def translate_domain_by(self, shift: tuple[int, ...]) -> IndexTransform: + """Shift the *input* domain by `shift`, preserving which cells are addressed. + + TensorStore's `translate_by`: the domain moves, and every output map is + re-offset so that new coordinate `c` addresses the cell that `c - shift` + addressed before. ArrayMaps are indexed positionally over the domain, so + their index arrays are unchanged. + """ + if len(shift) != self.input_rank: + raise ValueError(f"shift must have length {self.input_rank}, got {len(shift)}") + new_domain = self.domain.translate(shift) + new_output: list[OutputIndexMap] = [] + for m in self.output: + if isinstance(m, DimensionMap): + s = shift[m.input_dimension] + new_output.append( + DimensionMap( + input_dimension=m.input_dimension, + offset=m.offset - m.stride * s, + stride=m.stride, + ) + ) + else: + # ConstantMap: no input dependence. ArrayMap: positional over + # the domain, invariant under domain translation. + new_output.append(m) + return IndexTransform(domain=new_domain, output=tuple(new_output)) + + def translate_domain_to(self, origins: tuple[int, ...]) -> IndexTransform: + """Move the input domain so its per-dimension origins equal `origins`. + + TensorStore's `translate_to`; `translate_domain_to((0,) * rank)` + re-zeros a view's coordinate system without changing which cells it + addresses. + """ + if len(origins) != self.input_rank: + raise ValueError(f"origins must have length {self.input_rank}, got {len(origins)}") + shift = tuple(o - m for o, m in zip(origins, self.domain.inclusive_min, strict=True)) + return self.translate_domain_by(shift) + + @property + def oindex(self) -> _OIndexHelper: + return _OIndexHelper(self) + + @property + def vindex(self) -> _VIndexHelper: + return _VIndexHelper(self) + + +def _intersect( + transform: IndexTransform, output_domain: IndexDomain +) -> ( + tuple[ + IndexTransform, + dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]] | None, + ] + | None +): + """Intersect a transform with an output domain (e.g., a chunk's bounds). + + For each output dimension, restrict to storage coordinates within + `[output_domain.inclusive_min[d], output_domain.exclusive_max[d])`. + + Two flavours of fancy indexing require different treatment, distinguished by + the ArrayMaps' dependency axes (see `_array_map_dependency_axes`): + + - **orthogonal** (`oindex`): each ArrayMap varies over a single, distinct + input axis, forming an outer product. Every output dimension is intersected + independently and the input domain narrowed per axis. + - **correlated** (`vindex`): the ArrayMaps share their (broadcast) dependency + axes and scatter through a single flat index. A point survives only if ALL + its storage coordinates fall within the output domain; residual slice + dimensions are intersected independently, as in the orthogonal case. + + A `None` `input_dimension` marks a correlated map, so any such map routes the + whole transform through the correlated intersection. + + Returns `None` if the intersection is empty. + """ + if output_domain.ndim != transform.output_rank: + raise ValueError( + f"output_domain rank ({output_domain.ndim}) != " + f"transform output rank ({transform.output_rank})" + ) + + correlated_dims = [ + i + for i, m in enumerate(transform.output) + if isinstance(m, ArrayMap) and m.input_dimension is None + ] + if len(correlated_dims) > 0: + return _intersect_correlated(transform, output_domain, correlated_dims) + return _intersect_orthogonal(transform, output_domain) + + +def _intersect_dimension_map( + m: DimensionMap, input_lo: int, input_hi: int, lo: int, hi: int +) -> tuple[int, int] | None: + """Narrow a DimensionMap's input range to storage coordinates in `[lo, hi)`. + + `input_lo`/`input_hi` are the current (possibly already narrowed) input + range for the map's axis. Returns the new `(input_lo, input_hi)` or `None` + if no input produces an in-bounds storage coordinate. + """ + if input_lo >= input_hi: + return None + if m.stride > 0: + new_input_lo = max(input_lo, math.ceil((lo - m.offset) / m.stride)) + new_input_hi = min(input_hi, math.ceil((hi - m.offset) / m.stride)) + elif m.stride < 0: + new_input_lo = max(input_lo, math.ceil((hi - 1 - m.offset) / m.stride)) + new_input_hi = min(input_hi, math.ceil((lo - 1 - m.offset) / m.stride)) + else: + if lo <= m.offset < hi: + new_input_lo, new_input_hi = input_lo, input_hi + else: + return None + if new_input_lo >= new_input_hi: + return None + return new_input_lo, new_input_hi + + +def _intersect_orthogonal( + transform: IndexTransform, output_domain: IndexDomain +) -> ( + tuple[ + IndexTransform, + dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]] | None, + ] + | None +): + """Intersect a transform with no correlated ArrayMaps. + + Every output dimension is intersected independently. Multiple ArrayMaps bound + to distinct input dimensions form an outer product, so each array's surviving + *output* positions are tracked separately. + """ + new_min = list(transform.domain.inclusive_min) + new_max = list(transform.domain.exclusive_max) + new_output: list[OutputIndexMap] = [] + out_positions: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {} + + for out_dim, m in enumerate(transform.output): + lo = output_domain.inclusive_min[out_dim] + hi = output_domain.exclusive_max[out_dim] + + if isinstance(m, ConstantMap): + if lo <= m.offset < hi: + new_output.append(m) + else: + return None + + elif isinstance(m, DimensionMap): + d = m.input_dimension + narrowed = _intersect_dimension_map(m, new_min[d], new_max[d], lo, hi) + if narrowed is None: + return None + new_min[d], new_max[d] = narrowed + new_output.append(m) + + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + # Orthogonal: the array varies over a single axis (its dependency + # axis, or `input_dimension` for a degenerate length-1 array). Filter + # along that axis and keep the array at full input rank so the + # singleton axes it broadcasts over are preserved. + d = _array_map_dependent_axis(m) + storage = m.offset + m.stride * m.index_array + mask = (storage >= lo) & (storage < hi) + # The array is singleton on every axis but `d`, so its mask reduces + # to a 1-D vector along `d`. + survivors = np.nonzero(mask.reshape(-1))[0].astype(np.intp) + if survivors.size == 0: + return None + filtered = np.take(m.index_array, survivors, axis=d) + new_output.append( + ArrayMap( + index_array=np.asarray(filtered, dtype=np.intp), + offset=m.offset, + stride=m.stride, + input_dimension=m.input_dimension, + ) + ) + new_max[d] = new_min[d] + int(survivors.size) + out_positions[out_dim] = survivors + + new_domain = IndexDomain( + inclusive_min=tuple(new_min), + exclusive_max=tuple(new_max), + ) + result = IndexTransform(domain=new_domain, output=tuple(new_output)) + + # Hand back the surviving output positions in the shape the bridge expects: + # None (no arrays), a single vector (one array), or a per-output-dim dict + # (>= 2 orthogonal arrays → outer product). + out_indices: ( + dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]] | None + ) + if len(out_positions) == 0: + out_indices = None + elif len(out_positions) == 1: + out_indices = next(iter(out_positions.values())) + else: + out_indices = out_positions + return (result, out_indices) + + +def _intersect_correlated( + transform: IndexTransform, + output_domain: IndexDomain, + correlated_dims: list[int], +) -> tuple[IndexTransform, np.ndarray[Any, np.dtype[np.intp]]] | None: + """Intersect a correlated (vindex) transform with an output domain. + + The correlated ArrayMaps share their broadcast (dependency) axes; a broadcast + point survives only if ALL its storage coordinates fall within the output + domain. Residual DimensionMap dimensions are intersected independently (as in + the orthogonal case) and preserved, so a partial vindex — e.g. two coordinate + arrays over a 3-D array, leaving one slice dimension — resolves correctly. + + The surviving broadcast axes collapse to a single axis; the returned + `out_indices` is the flat scatter index into the (row-major flattened) + output buffer, of shape `(surviving_points,) + (residual slice sizes)`. + """ + corr_maps = [cast("ArrayMap", transform.output[i]) for i in correlated_dims] + + # Mixing correlated and orthogonal ArrayMaps in one transform is not produced + # by any single selection and is not supported here. + orthogonal_array_dims = [ + i + for i, m in enumerate(transform.output) + if isinstance(m, ArrayMap) and m.input_dimension is not None + ] + if len(orthogonal_array_dims) > 0: + raise NotImplementedError( + "intersecting a transform with both correlated and orthogonal " + "ArrayMaps is not supported" + ) + + # The broadcast (dependency) axes are shared by every correlated map; they are + # the leading axes of the domain, followed by the residual slice axes. + broadcast_axes = _array_map_dependency_axes(corr_maps[0].index_array) + broadcast_shape = tuple(corr_maps[0].index_array.shape[a] for a in broadcast_axes) + + # Joint bounds mask over the broadcast block. + combined: np.ndarray[Any, np.dtype[np.bool_]] | None = None + for out_dim in correlated_dims: + cm = cast("ArrayMap", transform.output[out_dim]) + storage = cm.offset + cm.stride * cm.index_array + lo = output_domain.inclusive_min[out_dim] + hi = output_domain.exclusive_max[out_dim] + mask = (storage >= lo) & (storage < hi) + combined = mask if combined is None else (combined & mask) + assert combined is not None + # The correlated maps are singleton on every non-broadcast axis, so the mask + # collapses (C-order) to the broadcast block. + combined_bcast = combined.reshape(broadcast_shape) + surviving = np.nonzero(combined_bcast.reshape(-1))[0].astype(np.intp) + if surviving.size == 0: + return None + + # Intersect residual (slice / constant) dimensions independently. Slice dims + # are ordered by input dimension so their flat-buffer strides are row-major. + slice_dims: list[tuple[int, int, int, int, DimensionMap]] = [] # (in_dim, lo, hi, full, m) + for out_dim, m in enumerate(transform.output): + if out_dim in correlated_dims: + continue + lo = output_domain.inclusive_min[out_dim] + hi = output_domain.exclusive_max[out_dim] + if isinstance(m, ConstantMap): + if not (lo <= m.offset < hi): + return None + elif isinstance(m, DimensionMap): + d = m.input_dimension + input_lo = transform.domain.inclusive_min[d] + input_hi = transform.domain.exclusive_max[d] + narrowed = _intersect_dimension_map(m, input_lo, input_hi, lo, hi) + if narrowed is None: + return None + slice_dims.append((d, narrowed[0], narrowed[1], input_hi - input_lo, m)) + slice_dims.sort(key=lambda item: item[0]) + + n_points = int(surviving.size) + n_slice = len(slice_dims) + corr_values = { + out_dim: cast("ArrayMap", transform.output[out_dim]) + .index_array.reshape(broadcast_shape) + .reshape(-1)[surviving] + for out_dim in correlated_dims + } + + # New domain: the collapsed broadcast axis, then one axis per residual slice. + new_min = [0] + new_max = [n_points] + new_input_dim_of = {} + for new_axis, (d, nlo, nhi, _full, _m) in enumerate(slice_dims, start=1): + new_min.append(nlo) + new_max.append(nhi) + new_input_dim_of[d] = new_axis + new_domain = IndexDomain(inclusive_min=tuple(new_min), exclusive_max=tuple(new_max)) + + corr_shape = (n_points,) + (1,) * n_slice + new_output: list[OutputIndexMap] = [] + for out_dim, m in enumerate(transform.output): + if out_dim in correlated_dims: + corr = cast("ArrayMap", m) + new_output.append( + ArrayMap( + index_array=corr_values[out_dim].reshape(corr_shape).astype(np.intp), + offset=corr.offset, + stride=corr.stride, + ) + ) + elif isinstance(m, ConstantMap): + new_output.append(m) + else: + assert isinstance(m, DimensionMap) + new_output.append( + DimensionMap( + input_dimension=new_input_dim_of[m.input_dimension], + offset=m.offset, + stride=m.stride, + ) + ) + result = IndexTransform(domain=new_domain, output=tuple(new_output)) + + # Flat scatter index into the row-major output buffer of shape + # (broadcast points, residual slice sizes...): flat = point * prod(slice) + + # (row-major offset within the slice block). + prod_slice = 1 + for _d, _lo, _hi, full, _m in slice_dims: + prod_slice *= full + out_indices: np.ndarray[Any, np.dtype[np.intp]] = (surviving * prod_slice).reshape( + (n_points,) + (1,) * n_slice + ) + running = 1 + for j in range(n_slice - 1, -1, -1): + _d, nlo, nhi, full, _m = slice_dims[j] + coords = np.arange(nlo, nhi, dtype=np.intp) * running + shape = [1] * (1 + n_slice) + shape[1 + j] = coords.size + out_indices = out_indices + coords.reshape(shape) + running *= full + return (result, out_indices.astype(np.intp)) + + +def _normalize_basic_selection(selection: Any, ndim: int) -> tuple[int | slice | None, ...]: + """Normalize a selection to a tuple of int, slice, or None (newaxis), + expanding ellipsis and padding with slice(None) as needed. + """ + if not isinstance(selection, tuple): + selection = (selection,) + + # Count non-newaxis, non-ellipsis entries to determine how many real dims are addressed + n_newaxis = sum(1 for s in selection if s is None) + has_ellipsis = any(s is Ellipsis for s in selection) + n_real = len(selection) - n_newaxis - (1 if has_ellipsis else 0) + + if n_real > ndim: + raise IndexError( + f"too many indices for array: array has {ndim} dimensions, but {n_real} were indexed" + ) + + result: list[int | slice | None] = [] + ellipsis_seen = False + for sel in selection: + if sel is Ellipsis: + if ellipsis_seen: + raise IndexError("an index can only have a single ellipsis ('...')") + ellipsis_seen = True + num_missing = ndim - n_real + result.extend([slice(None)] * num_missing) + elif isinstance(sel, (int, np.integer)): + result.append(int(sel)) + elif isinstance(sel, slice) or sel is None: + result.append(sel) + else: + raise IndexError(f"unsupported selection type for basic indexing: {type(sel)!r}") + + # Pad remaining dimensions with slice(None) + while sum(1 for s in result if s is not None) < ndim: + result.append(slice(None)) + + return tuple(result) + + +def _reindex_array( + m: ArrayMap, + normalized: tuple[int | slice | None, ...], + domain: IndexDomain, +) -> np.ndarray[Any, np.dtype[np.intp]]: + """Apply basic indexing operations to an ArrayMap's index_array. + + The array's axes correspond to the transform's input dimensions (0-indexed + over the domain shape). Each axis is either a **dependency axis** — the array + genuinely varies with that input dimension — or a **singleton** axis it + broadcasts over. Integer indexing, slicing, or newaxis is applied to the + array only along its dependency axes; a selection on a singleton axis does not + touch the array's values (it just narrows or drops that broadcast axis). + """ + dependent = set(_array_map_dependency_axes(m.index_array)) + if m.input_dimension is not None: + # Degenerate length-1 orthogonal selection: the recorded axis is a + # dependency even though its size (1) makes it look singleton. + dependent.add(m.input_dimension) + arr = m.index_array + + # Build a numpy indexing tuple: one entry per old input dimension + idx: list[Any] = [] + old_dim = 0 + newaxis_positions: list[int] = [] + result_axis = 0 + + for sel in normalized: + if sel is None: + newaxis_positions.append(result_axis) + result_axis += 1 + elif isinstance(sel, int): + if old_dim < arr.ndim: + if old_dim in dependent: + # Convert absolute domain coordinate to 0-based array index + idx.append(sel - domain.inclusive_min[old_dim]) + else: + # Broadcast axis: keep the single element and drop the axis. + idx.append(0) + old_dim += 1 + else: + # sel: slice (normalized: tuple[int | slice | None, ...]) + if old_dim < arr.ndim: + if old_dim in dependent: + lo = domain.inclusive_min[old_dim] + hi = domain.exclusive_max[old_dim] + # Bounds are literal domain coordinates; the stored array is + # indexed positionally, so shift by the domain origin. + start, step, _origin, size = _resolve_slice_ts(sel, old_dim, lo, hi) + pos = start - lo + idx.append(slice(pos, pos + size * step, step)) + else: + # Broadcast axis: preserve the singleton (it still broadcasts + # over the narrowed domain), regardless of the slice bounds. + idx.append(slice(None)) + old_dim += 1 + result_axis += 1 + + result = arr[tuple(idx)] if idx else arr + + for pos in newaxis_positions: + result = np.expand_dims(result, axis=pos) + + return np.asarray(result, dtype=np.intp) + + +_FANCY_AFTER_FANCY_MSG = ( + "applying a fancy (orthogonal/vectorized) selection to a view that already " + "has a fancy-indexed axis is not supported (fancy-after-fancy composition): " + "the new coordinates would index a broadcast axis of the existing selection. " + "Materialize the view first with `.result()` and index the array, or reorder " + "the selections so the fancy step is applied last." +) + + +def _guard_fancy_after_fancy(m: ArrayMap, fancy_dims: set[int] | list[int]) -> None: + """Reject a fancy step that lands on a broadcast axis of an existing ArrayMap. + + A new orthogonal/vectorized selection can only be absorbed into an existing + ArrayMap along the axes that map genuinely varies over (its dependency axes, + plus the recorded `input_dimension` for a degenerate length-1 orthogonal + selection). A fancy index targeting any other axis — a singleton axis the map + merely broadcasts over — cannot be reindexed and used to leak a raw NumPy + `IndexError` at resolve time. Raise a clear `NotImplementedError` instead. + """ + dependent = set(_array_map_dependency_axes(m.index_array)) + if m.input_dimension is not None: + dependent.add(m.input_dimension) + for d in fancy_dims: + if d < m.index_array.ndim and d not in dependent: + raise NotImplementedError(_FANCY_AFTER_FANCY_MSG) + + +def _reindex_array_oindex( + arr: np.ndarray[Any, np.dtype[np.intp]], + normalized: tuple[Any, ...] | list[Any], + domain: IndexDomain, +) -> np.ndarray[Any, np.dtype[np.intp]]: + """Apply oindex/vindex selection to an existing ArrayMap's index_array. + + Each old input dimension gets either an array (fancy index that axis) + or a slice applied to the corresponding array axis. + """ + idx: list[Any] = [] + for old_dim, sel in enumerate(normalized): + if old_dim >= arr.ndim: + break + lo = domain.inclusive_min[old_dim] + if isinstance(sel, np.ndarray): + # Values are literal domain coordinates; the stored array is + # indexed positionally, so shift by the domain origin. + idx.append(sel - lo) + elif isinstance(sel, slice): + hi = domain.exclusive_max[old_dim] + start, step, _origin, size = _resolve_slice_ts(sel, old_dim, lo, hi) + pos = start - lo + idx.append(slice(pos, pos + size * step, step)) + else: + idx.append(slice(None)) + + result = arr[tuple(idx)] if idx else arr + return np.asarray(result, dtype=np.intp) + + +def _apply_basic_indexing(transform: IndexTransform, selection: Any) -> IndexTransform: + """Apply basic indexing (int, slice, ellipsis, newaxis) to an IndexTransform.""" + normalized = _normalize_basic_selection(selection, transform.domain.ndim) + + new_inclusive_min: list[int] = [] + new_exclusive_max: list[int] = [] + old_dim = 0 + new_dim_idx = 0 + old_to_new_dim: dict[int, int] = {} + dropped_dims: set[int] = set() + + # Per old-dim: the slice parameters (for computing new output maps) + dim_slice_params: dict[int, tuple[int, int, int]] = {} # old_dim -> (start, stop, step) + dim_int_val: dict[int, int] = {} # old_dim -> integer index value + + for sel in normalized: + if sel is None: + # newaxis: add a size-1 dimension + new_inclusive_min.append(0) + new_exclusive_max.append(1) + new_dim_idx += 1 + elif isinstance(sel, int): + # Integer index: drop this input dimension. + # Negative indices are literal coordinates (TensorStore convention), + # NOT "from the end" like NumPy. The Array layer handles conversion. + lo = transform.domain.inclusive_min[old_dim] + hi = transform.domain.exclusive_max[old_dim] + idx = sel + if idx < lo or idx >= hi: + hint = _LITERAL_HINT if sel < 0 else "" + raise BoundsCheckError( + f"index {sel} is out of bounds for dimension {old_dim} " + f"(valid indices [{lo}, {hi})){hint}" + ) + dropped_dims.add(old_dim) + dim_int_val[old_dim] = idx + old_dim += 1 + else: + # sel: slice (normalized: tuple[int | slice | None, ...]) + lo = transform.domain.inclusive_min[old_dim] + hi = transform.domain.exclusive_max[old_dim] + + # TensorStore semantics: bounds are literal coordinates; a step-1 + # slice keeps them as the new domain, a strided slice's domain is + # [trunc(start/step), trunc(start/step) + size). + start, step, origin, size = _resolve_slice_ts(sel, old_dim, lo, hi) + new_inclusive_min.append(origin) + new_exclusive_max.append(origin + size) + dim_slice_params[old_dim] = (start, step, origin) + old_to_new_dim[old_dim] = new_dim_idx + new_dim_idx += 1 + old_dim += 1 + + new_domain = IndexDomain( + inclusive_min=tuple(new_inclusive_min), + exclusive_max=tuple(new_exclusive_max), + ) + + # Now update output maps + new_output: list[OutputIndexMap] = [] + for m in transform.output: + if isinstance(m, ConstantMap): + new_output.append(m) + elif isinstance(m, DimensionMap): + d = m.input_dimension + if d in dropped_dims: + # Integer index: this output becomes constant + new_offset = m.offset + m.stride * dim_int_val[d] + new_output.append(ConstantMap(offset=new_offset)) + elif d in old_to_new_dim: + # Slice: new coordinate `origin + k` maps to old coordinate + # `start + k*step`, i.e. old = start - step*origin + step*new. + start, step, origin = dim_slice_params[d] + new_offset = m.offset + m.stride * (start - step * origin) + new_stride = m.stride * step + new_input_dim = old_to_new_dim[d] + new_output.append( + DimensionMap( + input_dimension=new_input_dim, offset=new_offset, stride=new_stride + ) + ) + else: + raise RuntimeError(f"unexpected: dimension {d} not handled") + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + new_arr = _reindex_array(m, normalized, transform.domain) + array_input_dim: int | None = None + if m.input_dimension is not None: + array_input_dim = old_to_new_dim.get(m.input_dimension, m.input_dimension) + new_output.append( + ArrayMap( + index_array=new_arr, + offset=m.offset, + stride=m.stride, + input_dimension=array_input_dim, + ) + ) + + return IndexTransform(domain=new_domain, output=tuple(new_output)) + + +def _array_map_dependency_axes(index_array: np.ndarray[Any, Any]) -> tuple[int, ...]: + """Return the input axes on which a normalized index array varies. + + Normalized `ArrayMap` index arrays carry the full input rank of their + enclosing transform: an axis the array varies over has its full size, while + an axis the array is independent of is a singleton (size 1). The dependency + axes are therefore exactly the non-singleton axes. An orthogonal (`oindex`) + array depends on a single axis; a vectorized (`vindex`) array depends on all + of the (shared) broadcast axes. + """ + return tuple(axis for axis, size in enumerate(index_array.shape) if size != 1) + + +def _array_map_dependent_axis(m: ArrayMap) -> int: + """Return the single input axis an orthogonal `ArrayMap` varies over. + + Normally this is the array's one non-singleton axis. A degenerate length-1 + orthogonal selection normalizes to an all-singleton shape (its dependency + axes are empty and indistinguishable by shape from a scalar), so + `input_dimension` breaks the tie — it records the axis the map binds. + """ + dep = _array_map_dependency_axes(m.index_array) + if len(dep) == 1: + return dep[0] + if m.input_dimension is not None: + return m.input_dimension + raise ValueError( + f"orthogonal ArrayMap must vary over exactly one axis; got dependency " + f"axes {dep} with input_dimension={m.input_dimension}" + ) + + +def _reshape_to_axis( + values: np.ndarray[Any, np.dtype[np.intp]], axis: int, ndim: int +) -> np.ndarray[Any, np.dtype[np.intp]]: + """Reshape a 1-D selection to full rank `ndim` varying only along `axis`. + + The result has `values` laid out along `axis` and singleton (size-1) axes + everywhere else, so its dependency axis is derivable from its shape. + """ + flat = np.asarray(values, dtype=np.intp).ravel() + shape = [1] * ndim + shape[axis] = flat.shape[0] + return flat.reshape(shape) + + +class _OIndexHelper: + """Helper that provides orthogonal (outer) indexing via `transform.oindex[...]`.""" + + def __init__(self, transform: IndexTransform) -> None: + self._transform = transform + + def __getitem__(self, selection: Any) -> IndexTransform: + return _apply_oindex(self._transform, selection) + + +def _normalize_oindex_selection( + selection: Any, ndim: int +) -> tuple[np.ndarray[Any, np.dtype[np.intp]] | slice, ...]: + """Normalize an oindex selection: arrays, slices, booleans, integers.""" + if not isinstance(selection, tuple): + selection = (selection,) + + # Expand ellipsis + has_ellipsis = any(s is Ellipsis for s in selection) + n_ellipsis = 1 if has_ellipsis else 0 + n_real = len(selection) - n_ellipsis + + result: list[np.ndarray[Any, np.dtype[np.intp]] | slice] = [] + for sel in selection: + if sel is Ellipsis: + num_missing = ndim - n_real + result.extend([slice(None)] * num_missing) + elif isinstance(sel, np.ndarray) and sel.dtype == np.bool_: + # Boolean array -> integer indices + (indices,) = np.nonzero(sel) + result.append(indices.astype(np.intp)) + elif isinstance(sel, np.ndarray): + result.append(sel.astype(np.intp)) + elif isinstance(sel, slice): + result.append(sel) + elif isinstance(sel, (int, np.integer)): + # Convert integer scalars to 1-element arrays for orthogonal indexing + result.append(np.array([int(sel)], dtype=np.intp)) + elif isinstance(sel, (list, tuple)): + result.append(np.asarray(sel, dtype=np.intp)) + else: + result.append(sel) + + # Pad with slice(None) + while len(result) < ndim: + result.append(slice(None)) + + return tuple(result) + + +def _apply_oindex(transform: IndexTransform, selection: Any) -> IndexTransform: + """Apply orthogonal indexing to an IndexTransform. + + Each index array is applied independently per dimension (outer product). + """ + normalized = _normalize_oindex_selection(selection, transform.domain.ndim) + + new_inclusive_min: list[int] = [] + new_exclusive_max: list[int] = [] + new_dim_idx = 0 + old_to_new_dim: dict[int, int] = {} + + # Info per old dim + dim_array: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {} + dim_slice_params: dict[int, tuple[int, int, int]] = {} + + for old_dim, sel in enumerate(normalized): + if isinstance(sel, np.ndarray): + lo = transform.domain.inclusive_min[old_dim] + hi = transform.domain.exclusive_max[old_dim] + # Index-array values are literal domain coordinates; the fancy dim + # they create gets a fresh zero-origin [0, n) domain (TensorStore). + _check_array_in_bounds(sel, lo, hi) + dim_array[old_dim] = sel + new_inclusive_min.append(0) + new_exclusive_max.append(len(sel)) + old_to_new_dim[old_dim] = new_dim_idx + new_dim_idx += 1 + else: + # sel: slice (_normalize_oindex_selection returns + # tuple[np.ndarray | slice, ...]) + lo = transform.domain.inclusive_min[old_dim] + hi = transform.domain.exclusive_max[old_dim] + start, step, origin, size = _resolve_slice_ts(sel, old_dim, lo, hi) + new_inclusive_min.append(origin) + new_exclusive_max.append(origin + size) + dim_slice_params[old_dim] = (start, step, origin) + old_to_new_dim[old_dim] = new_dim_idx + new_dim_idx += 1 + + new_domain = IndexDomain( + inclusive_min=tuple(new_inclusive_min), + exclusive_max=tuple(new_exclusive_max), + ) + + new_output: list[OutputIndexMap] = [] + for m in transform.output: + if isinstance(m, ConstantMap): + new_output.append(m) + elif isinstance(m, DimensionMap): + d = m.input_dimension + if d in dim_array: + new_axis = old_to_new_dim[d] + # Normalize to full input rank: the selection varies along its own + # new axis and is singleton on every other axis. The dependency + # axis is then derivable from the shape (a single non-singleton + # axis marks the selection orthogonal / outer-product rather than + # vectorized). `input_dimension` is kept populated as a + # compatibility shim for consumers not yet migrated to the + # shape-derived classifier. + full_arr = _reshape_to_axis(dim_array[d], new_axis, new_dim_idx) + new_output.append( + ArrayMap( + index_array=full_arr, + offset=m.offset, + stride=m.stride, + input_dimension=new_axis, + ) + ) + elif d in dim_slice_params: + start, step, origin = dim_slice_params[d] + new_offset = m.offset + m.stride * (start - step * origin) + new_stride = m.stride * step + new_input_dim = old_to_new_dim[d] + new_output.append( + DimensionMap( + input_dimension=new_input_dim, offset=new_offset, stride=new_stride + ) + ) + else: + raise RuntimeError(f"unexpected: dimension {d} not handled") + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + _guard_fancy_after_fancy(m, list(dim_array.keys())) + new_arr = _reindex_array_oindex(m.index_array, normalized, transform.domain) + array_input_dim: int | None = None + if m.input_dimension is not None: + array_input_dim = old_to_new_dim.get(m.input_dimension, m.input_dimension) + new_output.append( + ArrayMap( + index_array=new_arr, + offset=m.offset, + stride=m.stride, + input_dimension=array_input_dim, + ) + ) + + return IndexTransform(domain=new_domain, output=tuple(new_output)) + + +class _VIndexHelper: + """Helper that provides vectorized (fancy) indexing via `transform.vindex[...]`.""" + + def __init__(self, transform: IndexTransform) -> None: + self._transform = transform + + def __getitem__(self, selection: Any) -> IndexTransform: + return _apply_vindex(self._transform, selection) + + +def _apply_vindex(transform: IndexTransform, selection: Any) -> IndexTransform: + """Apply vectorized indexing to an IndexTransform. + + All array indices are broadcast together. Broadcast dimensions are prepended, + followed by non-array (slice) dimensions. + """ + if not isinstance(selection, tuple): + selection = (selection,) + + # Expand ellipsis and count consumed dimensions + # Boolean arrays with ndim > 1 consume ndim dims + n_consumed = 0 + for s in selection: + if s is Ellipsis: + continue + if isinstance(s, np.ndarray) and s.dtype == np.bool_ and s.ndim > 1: + n_consumed += s.ndim + else: + n_consumed += 1 + ndim = transform.domain.ndim + + expanded: list[Any] = [] + for sel in selection: + if sel is Ellipsis: + num_missing = ndim - n_consumed + expanded.extend([slice(None)] * num_missing) + else: + expanded.append(sel) + # Count dimensions already consumed by expanded entries + n_expanded_dims = 0 + for sel in expanded: + if isinstance(sel, np.ndarray) and sel.dtype == np.bool_ and sel.ndim > 1: + n_expanded_dims += sel.ndim + else: + n_expanded_dims += 1 + while n_expanded_dims < ndim: + expanded.append(slice(None)) + n_expanded_dims += 1 + + # Convert booleans, lists, ints to integer arrays + processed: list[np.ndarray[Any, np.dtype[np.intp]] | slice] = [] + for sel in expanded: + if isinstance(sel, np.ndarray) and sel.dtype == np.bool_: + indices_tuple = np.nonzero(sel) + processed.extend(indices.astype(np.intp) for indices in indices_tuple) + elif isinstance(sel, np.ndarray): + processed.append(sel.astype(np.intp)) + elif isinstance(sel, (list, tuple)): + processed.append(np.asarray(sel, dtype=np.intp)) + elif isinstance(sel, (int, np.integer)): + processed.append(np.array([int(sel)], dtype=np.intp)) + else: + processed.append(sel) + + # Separate array dims and slice dims + array_dims: list[int] = [] + slice_dims: list[int] = [] + arrays: list[np.ndarray[Any, np.dtype[np.intp]]] = [] + + for i, sel in enumerate(processed): + if isinstance(sel, np.ndarray): + lo = transform.domain.inclusive_min[i] + hi = transform.domain.exclusive_max[i] + _check_array_in_bounds(sel, lo, hi) + array_dims.append(i) + arrays.append(sel) + else: + slice_dims.append(i) + + # Broadcast all arrays together + broadcast_arrays: list[np.ndarray[Any, np.dtype[np.intp]]] + if len(arrays) > 0: + broadcast_arrays = list(np.broadcast_arrays(*arrays)) + broadcast_shape = broadcast_arrays[0].shape + else: + broadcast_arrays = [] + broadcast_shape = () + + # Build new domain: broadcast dims first, then slice dims + new_inclusive_min: list[int] = [] + new_exclusive_max: list[int] = [] + + # Broadcast dimensions + for s in broadcast_shape: + new_inclusive_min.append(0) + new_exclusive_max.append(s) + + # Slice dimensions (preserved-domain literal semantics, like basic indexing) + slice_dim_params: dict[int, tuple[int, int, int]] = {} + for old_dim in slice_dims: + sel = processed[old_dim] + assert isinstance(sel, slice) + lo = transform.domain.inclusive_min[old_dim] + hi = transform.domain.exclusive_max[old_dim] + start, step, origin, size = _resolve_slice_ts(sel, old_dim, lo, hi) + new_inclusive_min.append(origin) + new_exclusive_max.append(origin + size) + slice_dim_params[old_dim] = (start, step, origin) + + new_domain = IndexDomain( + inclusive_min=tuple(new_inclusive_min), + exclusive_max=tuple(new_exclusive_max), + ) + + # Build output maps + array_dim_to_broadcast: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {} + for i, d in enumerate(array_dims): + array_dim_to_broadcast[d] = broadcast_arrays[i] + + # New dim index for slice dims starts after broadcast dims + n_broadcast_dims = len(broadcast_shape) + + new_output: list[OutputIndexMap] = [] + for m in transform.output: + if isinstance(m, ConstantMap): + new_output.append(m) + elif isinstance(m, DimensionMap): + d = m.input_dimension + if d in array_dim_to_broadcast: + # Normalize to full input rank: the broadcast (correlated) axes + # come first, followed by a singleton axis per slice dimension. + # Every vectorized array shares the same broadcast axes, so the + # dependency axes derived from the shape coincide — the signature + # of a pointwise scatter rather than an outer product. + broadcast_arr = array_dim_to_broadcast[d] + full_arr = broadcast_arr.reshape(broadcast_shape + (1,) * len(slice_dims)) + new_output.append( + ArrayMap( + index_array=full_arr, + offset=m.offset, + stride=m.stride, + ) + ) + else: + # Slice dim: new coord `origin + k` maps to old `start + k*step` + start, step, origin = slice_dim_params[d] + new_offset = m.offset + m.stride * (start - step * origin) + new_stride = m.stride * step + new_input_dim = n_broadcast_dims + slice_dims.index(d) + new_output.append( + DimensionMap( + input_dimension=new_input_dim, offset=new_offset, stride=new_stride + ) + ) + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + _guard_fancy_after_fancy(m, array_dims) + new_arr = _reindex_array_oindex(m.index_array, processed, transform.domain) + new_output.append( + ArrayMap( + index_array=new_arr, + offset=m.offset, + stride=m.stride, + input_dimension=m.input_dimension, + ) + ) + + return IndexTransform(domain=new_domain, output=tuple(new_output)) + + +_LITERAL_HINT = ( + "; within this transform layer, indices are literal domain coordinates (the " + "public Array boundary wraps NumPy-style negatives before they reach here)" +) + + +def _trunc_div(a: int, b: int) -> int: + """Integer division rounded toward zero (C semantics), as TensorStore uses + for strided-slice domain origins — distinct from Python's floor division + for negative operands (`trunc(-9/2) == -4` where `-9 // 2 == -5`).""" + q = a // b + if q < 0 and q * b != a: + q += 1 + return q + + +def _resolve_slice_ts(sel: slice, dim: int, lo: int, hi: int) -> tuple[int, int, int, int]: + """Resolve a slice against domain `[lo, hi)` with TensorStore semantics. + + Slice bounds are **literal domain coordinates** — never from-the-end, never + clamped. Rules (each verified against tensorstore 0.1.84): + + - defaults: `start = lo`, `stop = hi`; + - a non-empty interval must be contained in the domain (no clamping — a + NumPy-style out-of-range or negative bound is an error, not a shorter or + wrapped result); + - an **empty** interval (`start == stop`) is valid anywhere; + - reversed bounds (`start > stop` with positive step) are an error, not + an empty result; + - the result's domain origin is `trunc(start/step)` (rounded toward + zero) and coordinate `origin + k` maps to input `start + k*step`. + + Returns `(start, step, origin, size)` in domain coordinates. + """ + step = 1 if sel.step is None else sel.step + if step <= 0: + # Negative steps are valid in TensorStore but not yet supported here; + # step 0 is invalid everywhere. + raise IndexError("slice step must be positive") + start = lo if sel.start is None else sel.start + stop = hi if sel.stop is None else sel.stop + if stop < start: + raise IndexError( + f"slice interval [{start}, {stop}) with step {step} does not specify " + f"a valid interval for dimension {dim} (start > stop)" + ) + size = -(-(stop - start) // step) # ceil((stop - start) / step) + if size > 0 and (start < lo or stop > hi): + hint = _LITERAL_HINT if (start < 0 or stop < 0) and lo >= 0 else "" + raise BoundsCheckError( + f"slice interval [{start}, {stop}) is not contained within domain " + f"[{lo}, {hi}) for dimension {dim}{hint}" + ) + origin = _trunc_div(start, step) + return start, step, origin, size + + +def _check_array_in_bounds(arr: np.ndarray[Any, np.dtype[np.intp]], lo: int, hi: int) -> None: + """Reject index-array values outside the domain `[lo, hi)`. + + Index-array values are literal domain coordinates (TensorStore semantics): + a value below `inclusive_min` is out of bounds rather than counting from + the end. Out-of-range values raise instead of silently wrapping. + """ + if arr.size == 0: + return + lo_val, hi_val = int(arr.min()), int(arr.max()) + if lo_val < lo: + hint = _LITERAL_HINT if lo_val < 0 and lo >= 0 else "" + raise BoundsCheckError( + f"index {lo_val} is out of bounds (valid indices [{lo}, {hi})){hint}" + ) + if hi_val >= hi: + raise BoundsCheckError(f"index {hi_val} is out of bounds (valid indices [{lo}, {hi}))") + + +def _validate_array_selection(selection: Any, shape: tuple[int, ...], mode: str) -> None: + """Validate array-based selections (orthogonal, vectorized). + + Rejects types that are not valid for coordinate/vectorized indexing. + Does not check bounds — the transform operations handle that. + """ + items = selection if isinstance(selection, tuple) else (selection,) + for sel in items: + if isinstance(sel, slice): + # vindex is coordinate-only (matches eager zarr): every axis needs an + # integer/boolean array, never a slice. Orthogonal (oindex) allows slices. + if mode == "vectorized": + raise VindexInvalidSelectionError( + "unsupported selection type for vectorized indexing; only " + "coordinate selection (tuple of integer arrays) and mask selection " + f"(single Boolean array) are supported; got {selection!r}" + ) + continue + if sel is Ellipsis or isinstance(sel, (int, np.integer)): + continue + if isinstance(sel, (list, np.ndarray)): + continue + raise IndexError(f"unsupported selection type for {mode} indexing: {type(sel)!r}") + + +def _validate_basic_selection(selection: Any) -> None: + """Validate that a selection only contains basic indexing types (int, slice, Ellipsis). + + Rejects None (newaxis), arrays, lists, floats, strings, etc. + """ + items = selection if isinstance(selection, tuple) else (selection,) + for s in items: + if s is Ellipsis or isinstance(s, (int, np.integer, slice)): + continue + raise IndexError(f"unsupported selection type for basic indexing: {type(s)!r}") + + +def selection_to_transform( + selection: Any, + transform: IndexTransform, + mode: Literal["basic", "orthogonal", "vectorized"], +) -> IndexTransform: + """Convert a user selection into a composed IndexTransform. + + Negative indices are treated as literal coordinates (TensorStore convention). + The caller (Array layer) is responsible for converting numpy-style negative + indices before calling this function. + """ + if mode == "basic": + _validate_basic_selection(selection) + return transform[selection] + elif mode == "orthogonal": + _validate_array_selection(selection, transform.domain.shape, mode) + return transform.oindex[selection] + elif mode == "vectorized": + _validate_array_selection(selection, transform.domain.shape, mode) + return transform.vindex[selection] + else: + raise ValueError(f"Unknown mode: {mode!r}") diff --git a/packages/zarr-indexing/tests/conformance/PROVENANCE.md b/packages/zarr-indexing/tests/conformance/PROVENANCE.md new file mode 100644 index 0000000000..6f75de56c3 --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/PROVENANCE.md @@ -0,0 +1,20 @@ +# Provenance of the ndsel conformance corpus + +The JSON fixtures in this directory (`point.json`, `box.json`, `slice.json`, +`points.json`, `transform.json`, `errors.json`) and `README.md` are **vendored, +unmodified**, from the ndsel reference repository. + +- **Source:** +- **Branch:** `main` (merge of PR #1, `fix/slice-origin-trunc`) +- **Commit:** `c59bc556c` (fixtures byte-identical to the previously vendored + `c132b4c1caa3205830ce35a42502363171f650a7`) +- **Path in source:** `conformance/` + +**Do not edit these files.** They are vendored as-is so that +`zarr_indexing`' ndsel message layer can be checked against the same +language-agnostic corpus every other ndsel implementation runs. To update the +corpus, re-vendor from a newer ndsel commit and update the commit SHA above. + +ndsel PR #1 (merged) corrected the `slice` desugaring origin from +`floor(a/s)` to `trunc(a/s)` (rounding toward zero), which matches +`zarr_indexing`' existing `_trunc_div` semantics. diff --git a/packages/zarr-indexing/tests/conformance/README.md b/packages/zarr-indexing/tests/conformance/README.md new file mode 100644 index 0000000000..ecb0c57ca2 --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/README.md @@ -0,0 +1,16 @@ +# ndsel conformance corpus + +Language-agnostic fixtures. Each file is a JSON array of cases. + +A **success** case: + { "name": "...", "input": , "normalized": } + +An **error** case: + { "name": "...", "input": , "error": "" } + +An implementation is conformant iff, for every success case, +`normalize(input)` equals `normalized` by structural JSON equality, and for +every error case, `normalize(input)` is rejected with the given reason code. + +The `normalized` value is a canonical `transform` body (the `kind` field is +omitted; implementations compare the transform structure). diff --git a/packages/zarr-indexing/tests/conformance/box.json b/packages/zarr-indexing/tests/conformance/box.json new file mode 100644 index 0000000000..e847872f86 --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/box.json @@ -0,0 +1,50 @@ +[ + { + "name": "box/2d-min-max", + "input": { "kind": "box", "inclusive_min": [0, 0], "exclusive_max": [3, 4] }, + "normalized": { + "input_rank": 2, + "input_inclusive_min": [0, 0], + "input_exclusive_max": [3, 4], + "input_labels": ["", ""], + "output": [ + { "offset": 0, "stride": 1, "input_dimension": 0 }, + { "offset": 0, "stride": 1, "input_dimension": 1 } + ] + } + }, + { + "name": "box/shape-only-origin-zero", + "input": { "kind": "box", "shape": [5] }, + "normalized": { + "input_rank": 1, + "input_inclusive_min": [0], + "input_exclusive_max": [5], + "input_labels": [""], + "output": [ { "offset": 0, "stride": 1, "input_dimension": 0 } ] + } + }, + { + "name": "box/inclusive-max", + "input": { "kind": "box", "inclusive_min": [2], "inclusive_max": [9] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [2], "input_exclusive_max": [10], + "input_labels": [""], + "output": [ { "offset": 0, "stride": 1, "input_dimension": 0 } ] + } + }, + { + "name": "box/implicit-and-infinite-bounds", + "input": { "kind": "box", "inclusive_min": [["-inf"], 0], "exclusive_max": [["+inf"], 4], "labels": ["t", ""] }, + "normalized": { + "input_rank": 2, + "input_inclusive_min": [["-inf"], 0], + "input_exclusive_max": [["+inf"], 4], + "input_labels": ["t", ""], + "output": [ + { "offset": 0, "stride": 1, "input_dimension": 0 }, + { "offset": 0, "stride": 1, "input_dimension": 1 } + ] + } + } +] diff --git a/packages/zarr-indexing/tests/conformance/errors.json b/packages/zarr-indexing/tests/conformance/errors.json new file mode 100644 index 0000000000..e5e08bab3c --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/errors.json @@ -0,0 +1,23 @@ +[ + { "name": "error/step-zero", "input": { "kind": "slice", "start": [0], "stop": [4], "step": [0] }, "error": "step_zero" }, + { "name": "error/negative-step", "input": { "kind": "slice", "start": [9], "stop": [0], "step": [-2] }, "error": "negative_step_unsupported" }, + { "name": "error/multiple-upper-bounds", "input": { "kind": "box", "shape": [3], "exclusive_max": [3] }, "error": "multiple_upper_bounds" }, + { "name": "error/rank-mismatch", "input": { "kind": "slice", "start": [0, 0], "stop": [4] }, "error": "rank_mismatch" }, + { "name": "error/unknown-kind", "input": { "kind": "bogus" }, "error": "unknown_kind" }, + { "name": "error/transform-multiple-upper-bounds", "input": { "kind": "transform", "input_shape": [3], "input_exclusive_max": [3] }, "error": "multiple_upper_bounds" }, + { "name": "error/transform-rank-mismatch", "input": { "kind": "transform", "input_rank": 2, "input_inclusive_min": [0] }, "error": "rank_mismatch" }, + { "name": "error/missing-kind", "input": { "coords": [1, 2] }, "error": "invalid_json" }, + { "name": "error/point-missing-coords", "input": { "kind": "point" }, "error": "invalid_json" }, + { "name": "error/point-bool-coord", "input": { "kind": "point", "coords": [true] }, "error": "invalid_json" }, + { "name": "error/slice-missing-stop", "input": { "kind": "slice", "start": [0] }, "error": "invalid_json" }, + { "name": "error/box-non-list-bound", "input": { "kind": "box", "inclusive_min": 5 }, "error": "invalid_json" }, + { "name": "error/points-bool-coord", "input": { "kind": "points", "coords": [[true]] }, "error": "invalid_json" }, + { "name": "error/integer-out-of-i64-range", "input": { "kind": "point", "coords": [99999999999999999999] }, "error": "invalid_json" }, + { "name": "error/box-inverted-bounds", "input": { "kind": "box", "inclusive_min": [5], "exclusive_max": [3] }, "error": "bounds_out_of_order" }, + { "name": "error/box-negative-shape", "input": { "kind": "box", "shape": [-3] }, "error": "bounds_out_of_order" }, + { "name": "error/transform-inverted-bounds", "input": { "kind": "transform", "input_inclusive_min": [0], "input_exclusive_max": [-1] }, "error": "bounds_out_of_order" }, + { "name": "error/output-map-conflict", "input": { "kind": "transform", "output": [{ "input_dimension": 0, "index_array": [1, 2] }] }, "error": "output_map_conflict" }, + { "name": "error/box-unknown-field", "input": { "kind": "box", "shapee": [3] }, "error": "unknown_field" }, + { "name": "error/point-unknown-field", "input": { "kind": "point", "coords": [1], "extra": true }, "error": "unknown_field" }, + { "name": "error/output-map-unknown-field", "input": { "kind": "transform", "output": [{ "offset": 0, "bogus": 1 }] }, "error": "unknown_field" } +] diff --git a/packages/zarr-indexing/tests/conformance/point.json b/packages/zarr-indexing/tests/conformance/point.json new file mode 100644 index 0000000000..99a5ea16d8 --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/point.json @@ -0,0 +1,30 @@ +[ + { + "name": "point/2d", + "input": { "kind": "point", "coords": [4, 7] }, + "normalized": { + "input_rank": 0, + "input_inclusive_min": [], + "input_exclusive_max": [], + "input_labels": [], + "output": [ { "offset": 4 }, { "offset": 7 } ] + } + }, + { + "name": "point/scalar-0d", + "input": { "kind": "point", "coords": [] }, + "normalized": { + "input_rank": 0, "input_inclusive_min": [], "input_exclusive_max": [], + "input_labels": [], "output": [] + } + }, + { + "name": "point/large-i64", + "input": { "kind": "point", "coords": [1152921504606846976] }, + "normalized": { + "input_rank": 0, "input_inclusive_min": [], "input_exclusive_max": [], + "input_labels": [], + "output": [ { "offset": 1152921504606846976 } ] + } + } +] diff --git a/packages/zarr-indexing/tests/conformance/points.json b/packages/zarr-indexing/tests/conformance/points.json new file mode 100644 index 0000000000..1ad92e12b1 --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/points.json @@ -0,0 +1,34 @@ +[ + { + "name": "points/three-2d", + "input": { "kind": "points", "coords": [[1, 10], [2, 20], [3, 30]] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [0], "input_exclusive_max": [3], + "input_labels": [""], + "output": [ + { "offset": 0, "stride": 1, "index_array": [1, 2, 3], "index_array_bounds": ["-inf", "+inf"] }, + { "offset": 0, "stride": 1, "index_array": [10, 20, 30], "index_array_bounds": ["-inf", "+inf"] } + ] + } + }, + { + "name": "points/1d", + "input": { "kind": "points", "coords": [[5], [9], [2]] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [0], "input_exclusive_max": [3], + "input_labels": [""], + "output": [ + { "offset": 0, "stride": 1, "index_array": [5, 9, 2], "index_array_bounds": ["-inf", "+inf"] } + ] + } + }, + { + "name": "points/empty", + "input": { "kind": "points", "coords": [] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [0], "input_exclusive_max": [0], + "input_labels": [""], + "output": [] + } + } +] diff --git a/packages/zarr-indexing/tests/conformance/slice.json b/packages/zarr-indexing/tests/conformance/slice.json new file mode 100644 index 0000000000..2f1a0694ce --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/slice.json @@ -0,0 +1,61 @@ +[ + { + "name": "slice/unit-step-preserves-frame", + "input": { "kind": "slice", "start": [5], "stop": [10] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [5], "input_exclusive_max": [10], + "input_labels": [""], + "output": [ { "offset": 0, "stride": 1, "input_dimension": 0 } ] + } + }, + { + "name": "slice/divisible-stride", + "input": { "kind": "slice", "start": [4], "stop": [10], "step": [2] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [2], "input_exclusive_max": [5], + "input_labels": [""], + "output": [ { "offset": 0, "stride": 2, "input_dimension": 0 } ] + } + }, + { + "name": "slice/nondivisible-stride-phase-offset", + "input": { "kind": "slice", "start": [5], "stop": [10], "step": [2] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [2], "input_exclusive_max": [5], + "input_labels": [""], + "output": [ { "offset": 1, "stride": 2, "input_dimension": 0 } ] + } + }, + { + "name": "slice/2d-mixed-step", + "input": { "kind": "slice", "start": [0, 5], "stop": [10, 10], "step": [2, 1] }, + "normalized": { + "input_rank": 2, + "input_inclusive_min": [0, 5], + "input_exclusive_max": [5, 10], + "input_labels": ["", ""], + "output": [ + { "offset": 0, "stride": 2, "input_dimension": 0 }, + { "offset": 0, "stride": 1, "input_dimension": 1 } + ] + } + }, + { + "name": "slice/negative-start-trunc-origin", + "input": { "kind": "slice", "start": [-9], "stop": [5], "step": [2] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [-4], "input_exclusive_max": [3], + "input_labels": [""], + "output": [ { "offset": -1, "stride": 2, "input_dimension": 0 } ] + } + }, + { + "name": "slice/negative-start-trunc-origin-step3", + "input": { "kind": "slice", "start": [-8], "stop": [6], "step": [3] }, + "normalized": { + "input_rank": 1, "input_inclusive_min": [-2], "input_exclusive_max": [3], + "input_labels": [""], + "output": [ { "offset": -2, "stride": 3, "input_dimension": 0 } ] + } + } +] diff --git a/packages/zarr-indexing/tests/conformance/transform.json b/packages/zarr-indexing/tests/conformance/transform.json new file mode 100644 index 0000000000..f26157aaab --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/transform.json @@ -0,0 +1,57 @@ +[ + { + "name": "transform/omitted-output-identity", + "input": { "kind": "transform", "input_inclusive_min": [0, 0], "input_exclusive_max": [3, 4] }, + "normalized": { + "input_rank": 2, "input_inclusive_min": [0, 0], "input_exclusive_max": [3, 4], + "input_labels": ["", ""], + "output": [ + { "offset": 0, "stride": 1, "input_dimension": 0 }, + { "offset": 0, "stride": 1, "input_dimension": 1 } + ] + } + }, + { + "name": "transform/implicit-bounds-and-labels", + "input": { + "kind": "transform", + "input_inclusive_min": [["-inf"], 7], + "input_exclusive_max": [["+inf"], 11], + "input_labels": ["x", "y"] + }, + "normalized": { + "input_rank": 2, + "input_inclusive_min": [["-inf"], 7], + "input_exclusive_max": [["+inf"], 11], + "input_labels": ["x", "y"], + "output": [ + { "offset": 0, "stride": 1, "input_dimension": 0 }, + { "offset": 0, "stride": 1, "input_dimension": 1 } + ] + } + }, + { + "name": "transform/explicit-output-all-three-map-kinds", + "input": { + "kind": "transform", + "input_inclusive_min": [0], + "input_exclusive_max": [3], + "output": [ + { "offset": 7 }, + { "input_dimension": 0, "stride": 2 }, + { "index_array": [1, 2, 3] } + ] + }, + "normalized": { + "input_rank": 1, + "input_inclusive_min": [0], + "input_exclusive_max": [3], + "input_labels": [""], + "output": [ + { "offset": 7 }, + { "offset": 0, "stride": 2, "input_dimension": 0 }, + { "offset": 0, "stride": 1, "index_array": [1, 2, 3], "index_array_bounds": ["-inf", "+inf"] } + ] + } + } +] diff --git a/packages/zarr-indexing/tests/test_chunk_resolution.py b/packages/zarr-indexing/tests/test_chunk_resolution.py new file mode 100644 index 0000000000..0738384e2b --- /dev/null +++ b/packages/zarr-indexing/tests/test_chunk_resolution.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from zarr.core.chunk_grids import ChunkGrid, FixedDimension, VaryingDimension + +from zarr_indexing import chunk_resolution +from zarr_indexing.chunk_resolution import iter_chunk_transforms, sub_transform_to_selections +from zarr_indexing.domain import IndexDomain +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform + +if TYPE_CHECKING: + import pytest + + +class TestChunkResolutionIdentity: + def test_single_chunk(self) -> None: + """Array fits in one chunk.""" + t = IndexTransform.from_shape((10,)) + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=10),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 1 + coords, sub_t, _ = results[0] + assert coords == (0,) + assert sub_t.domain.shape == (10,) + + def test_multiple_chunks_1d(self) -> None: + """1D array spanning 3 chunks.""" + t = IndexTransform.from_shape((30,)) + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=30),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 3 + coords_list = [r[0] for r in results] + assert (0,) in coords_list + assert (1,) in coords_list + assert (2,) in coords_list + + def test_multiple_chunks_2d(self) -> None: + """2D array spanning 2x3 chunks.""" + t = IndexTransform.from_shape((20, 30)) + grid = ChunkGrid( + dimensions=( + FixedDimension(size=10, extent=20), + FixedDimension(size=10, extent=30), + ) + ) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 6 + coords_list = [r[0] for r in results] + assert (0, 0) in coords_list + assert (1, 2) in coords_list + + +class TestChunkResolutionSliced: + def test_slice_within_chunk(self) -> None: + """Slice that falls within a single chunk.""" + # Chunk resolution consumes zero-origin transforms: the I/O layer + # normalizes preserved (user-facing) domains via translate_domain_to + # before resolving, so mirror that contract here. + t = IndexTransform.from_shape((100,))[5:8].translate_domain_to((0,)) + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=100),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 1 + coords, sub_t, _ = results[0] + assert coords == (0,) + assert isinstance(sub_t.output[0], DimensionMap) + assert sub_t.output[0].offset == 5 + + def test_slice_across_chunks(self) -> None: + """Slice that spans two chunks.""" + t = IndexTransform.from_shape((100,))[8:15] + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=100),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 2 + coords_list = [r[0] for r in results] + assert (0,) in coords_list + assert (1,) in coords_list + + +class TestChunkResolutionConstant: + def test_integer_index(self) -> None: + """Integer index produces constant map — single chunk per constant dim.""" + t = IndexTransform.from_shape((100, 100))[25, :] + grid = ChunkGrid( + dimensions=( + FixedDimension(size=10, extent=100), + FixedDimension(size=10, extent=100), + ) + ) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 10 + for coords, _, _ in results: + assert coords[0] == 2 + + +class TestChunkResolutionArray: + def test_array_index(self) -> None: + """Array index map — chunks determined by array values.""" + idx = np.array([5, 15, 25], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=idx),), + ) + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=30),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + coords_list = [r[0] for r in results] + assert (0,) in coords_list + assert (1,) in coords_list + assert (2,) in coords_list + + +class TestChunkResolutionSorted1D: + def test_matches_general_resolution_for_randomized_sorted_selections( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Direct partitioning matches the original resolver across varied inputs.""" + rng = np.random.default_rng(0) + grids = ( + ChunkGrid(dimensions=(FixedDimension(size=7, extent=30),)), + ChunkGrid(dimensions=(VaryingDimension(edges=(3, 4, 8, 5, 10), extent=30),)), + ) + + for grid in grids: + for _ in range(50): + idx = np.sort(rng.integers(0, 30, size=int(rng.integers(1, 80)))).astype(np.intp) + transform = IndexTransform.from_shape((30,)).vindex[idx] + direct = list(iter_chunk_transforms(transform, grid._dimensions)) + + with monkeypatch.context() as context: + context.setattr( + chunk_resolution, + "_one_dimensional_correlated_array_map", + lambda _transform: None, + ) + general = list(iter_chunk_transforms(transform, grid._dimensions)) + + assert [result[0] for result in direct] == [result[0] for result in general] + for direct_result, general_result in zip(direct, general, strict=True): + _, direct_t, direct_out = direct_result + _, general_t, general_out = general_result + assert direct_t.domain == general_t.domain + + direct_chunk_sel, direct_out_sel, direct_drop = sub_transform_to_selections( + direct_t, direct_out + ) + general_chunk_sel, general_out_sel, general_drop = sub_transform_to_selections( + general_t, general_out + ) + assert direct_drop == general_drop + np.testing.assert_array_equal(direct_chunk_sel[0], general_chunk_sel[0]) + np.testing.assert_array_equal(direct_out_sel[0], general_out_sel[0]) + + def test_sorted_vindex_partitions_chunks_without_intersection( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Sorted vectorized coordinates are sliced directly per touched chunk.""" + idx = np.array([0, 3, 4, 4, 9, 11], dtype=np.intp) + t = IndexTransform.from_shape((12,)).vindex[idx] + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=12),)) + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,), (2,)] + assert calls["n"] == 0 + + expected_chunk_indices = ([0, 3], [0, 0], [1, 3]) + expected_out_indices = ([0, 1], [2, 3], [4, 5]) + for result, expected_chunk, expected_out in zip( + results, expected_chunk_indices, expected_out_indices, strict=True + ): + _, sub_t, out_indices = result + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(sub_t, out_indices) + np.testing.assert_array_equal(chunk_sel[0], expected_chunk) + np.testing.assert_array_equal(out_sel[0], expected_out) + assert drop_axes == () + + def test_sorted_array_map_preserves_offset_and_stride(self) -> None: + """Storage partitioning retains the ArrayMap's offset and stride.""" + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=( + ArrayMap( + index_array=np.array([0, 1, 2], dtype=np.intp), + offset=1, + stride=3, + ), + ), + ) + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=8),)) + + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,)] + expected_chunk_indices = ([1], [0, 3]) + expected_out_indices = ([0], [1, 2]) + for result, expected_chunk, expected_out in zip( + results, expected_chunk_indices, expected_out_indices, strict=True + ): + _, sub_t, out_indices = result + chunk_sel, out_sel, _ = sub_transform_to_selections(sub_t, out_indices) + np.testing.assert_array_equal(chunk_sel[0], expected_chunk) + np.testing.assert_array_equal(out_sel[0], expected_out) + + def test_sorted_vindex_with_varying_chunks(self) -> None: + """Touched-boundary searches also support a non-uniform 1-D grid.""" + idx = np.array([0, 1, 2, 3, 5, 9], dtype=np.intp) + t = IndexTransform.from_shape((10,)).vindex[idx] + grid = ChunkGrid(dimensions=(VaryingDimension(edges=(2, 3, 5), extent=10),)) + + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,), (2,)] + expected_chunk_indices = ([0, 1], [0, 1], [0, 4]) + for result, expected_chunk in zip(results, expected_chunk_indices, strict=True): + _, sub_t, out_indices = result + chunk_sel, _, _ = sub_transform_to_selections(sub_t, out_indices) + np.testing.assert_array_equal(chunk_sel[0], expected_chunk) + + def test_sorted_vindex_with_zero_sized_dimension_uses_general_resolution( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A zero-sized grid cannot be partitioned by touched boundaries.""" + t = IndexTransform.from_shape((10,)).vindex[np.array([1], dtype=np.intp)] + grid = ChunkGrid(dimensions=(FixedDimension(size=0, extent=10),)) + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert results == [] + assert calls["n"] == 1 + + def test_unsorted_vindex_uses_general_resolution(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Unsorted coordinates continue through the general intersection logic.""" + t = IndexTransform.from_shape((12,)).vindex[np.array([9, 0, 4], dtype=np.intp)] + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=12),)) + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,), (2,)] + assert calls["n"] == 3 + + def test_sorted_oindex_uses_general_resolution(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Orthogonal ArrayMaps retain their existing domain-aware resolution.""" + t = IndexTransform.from_shape((12,)).oindex[np.array([0, 4, 9], dtype=np.intp)] + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=12),)) + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,), (2,)] + assert calls["n"] == 3 + + +def _count_intersect_calls(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]: + """Wrap `IndexTransform.intersect` with a call counter. + + Returns a mutable dict whose `"n"` entry is the number of times + `intersect` is invoked. Used to assert that candidate-chunk enumeration is + proportional to the *touched* chunks, not the dense bounding box between the + min and max touched chunk. + """ + calls = {"n": 0} + original = IndexTransform.intersect + + def counting(self: IndexTransform, output_domain: IndexDomain) -> object: + calls["n"] += 1 + return original(self, output_domain) + + monkeypatch.setattr(IndexTransform, "intersect", counting) + return calls + + +class TestChunkResolutionTouchedOnly: + """`iter_chunk_transforms` must enumerate only the chunks a fancy selection + actually touches — never the dense `range(min_chunk, max_chunk + 1)` bounding + box. These guard against a regression to bounding-box enumeration, whose cost + scales with grid size rather than with the number of selected coordinates. + """ + + def test_1d_sparse_vindex_enumerates_only_touched_chunks( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two far-apart coordinates on a 1000-chunk grid touch exactly 2 chunks. + + A dense bounding-box enumeration would intersect ~1000 candidate chunks; + touched-only enumeration intersects exactly 2. + """ + # 4000 elements, chunk size 4 -> 1000 chunks. coords 1 and 3997 land in + # chunk 0 and chunk 999 respectively (998 empty chunks between them). + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=4000),)) + t = IndexTransform.from_shape((4000,)).vindex[np.array([1, 3997], dtype=np.intp)] + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + coords = sorted(r[0] for r in results) + assert coords == [(0,), (999,)] + # Sorted 1-D coordinates are partitioned directly, without intersecting + # either the touched chunks or the 998 empty chunks between them. + assert calls["n"] == 0 + + def test_2d_orthogonal_enumerates_only_touched_chunks( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Orthogonal outer product of two 2-coordinate arrays touches 2x2 chunks. + + Per-dimension distinct touched chunks: {0, 999} on each axis. The outer + product is 2*2 = 4 candidate chunks (all survive), versus ~1e6 for a + dense 1000x1000 bounding box. + """ + grid = ChunkGrid( + dimensions=( + FixedDimension(size=4, extent=4000), + FixedDimension(size=4, extent=4000), + ) + ) + t = IndexTransform.from_shape((4000, 4000)).oindex[ + np.array([1, 3997], dtype=np.intp), np.array([2, 3998], dtype=np.intp) + ] + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + coords = sorted(r[0] for r in results) + assert coords == [(0, 0), (0, 999), (999, 0), (999, 999)] + assert calls["n"] == 4 + + def test_2d_correlated_vindex_enumerates_joint_touched_chunks( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two correlated (vindex) coordinate arrays scatter to 2 diagonal chunks. + + The two points (1, 2) and (3997, 3998) touch chunks (0, 0) and + (999, 999). Correlated coordinate arrays are grouped *jointly*, so + enumeration intersects exactly the 2 touched chunks — never the 2x2 + cartesian product of per-dimension distinct chunks, and never the dense + 1e6 grid. + """ + grid = ChunkGrid( + dimensions=( + FixedDimension(size=4, extent=4000), + FixedDimension(size=4, extent=4000), + ) + ) + t = IndexTransform.from_shape((4000, 4000)).vindex[ + np.array([1, 3997], dtype=np.intp), np.array([2, 3998], dtype=np.intp) + ] + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + coords = sorted(r[0] for r in results) + assert coords == [(0, 0), (999, 999)] + assert calls["n"] == 2 + + def test_2d_correlated_vindex_diagonal_is_linear_in_points( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A diagonal of P correlated points touches P chunks with O(P) intersections. + + Enumerating the cartesian product of per-dimension distinct chunk sets + would cost P**2 intersections (2500 here) — quadratic in the number of + selected points for the scattered selections of zarr-python gh-4174. + Joint grouping keeps resolution work proportional to the touched chunks. + """ + p = 50 + grid = ChunkGrid( + dimensions=( + FixedDimension(size=4, extent=4000), + FixedDimension(size=4, extent=4000), + ) + ) + # point i lands in chunk (2i, 2i): all per-dimension chunks distinct + coords_1d = np.arange(p, dtype=np.intp) * 8 + t = IndexTransform.from_shape((4000, 4000)).vindex[coords_1d, coords_1d] + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert sorted(r[0] for r in results) == [(2 * i, 2 * i) for i in range(p)] + assert calls["n"] == p + + +class TestSubTransformToSelections: + def test_constant_map(self) -> None: + """ConstantMap produces int selection + drop axis.""" + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(ConstantMap(offset=5),), + ) + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(t) + assert chunk_sel == (5,) + assert out_sel == () + assert drop_axes == () + + def test_dimension_map_stride_1(self) -> None: + """DimensionMap with stride=1 produces contiguous slice.""" + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(DimensionMap(input_dimension=0, offset=3, stride=1),), + ) + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(t) + assert chunk_sel == (slice(3, 13, 1),) + assert out_sel == (slice(0, 10),) + assert drop_axes == () + + def test_dimension_map_strided(self) -> None: + """DimensionMap with stride>1 produces strided slice.""" + t = IndexTransform( + domain=IndexDomain.from_shape((5,)), + output=(DimensionMap(input_dimension=0, offset=2, stride=3),), + ) + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(t) + assert chunk_sel == (slice(2, 17, 3),) + assert out_sel == (slice(0, 5),) + assert drop_axes == () + + def test_array_map(self) -> None: + """ArrayMap produces integer array selection.""" + arr = np.array([1, 5, 9], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=arr, offset=0, stride=1),), + ) + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(t) + assert isinstance(chunk_sel[0], np.ndarray) + np.testing.assert_array_equal(chunk_sel[0], arr) + # Without chunk_mask, out_sel falls back to domain-based slices + assert out_sel == (slice(0, 3),) + assert drop_axes == () + + def test_array_map_with_offset_stride(self) -> None: + """ArrayMap with offset and stride computes storage coords.""" + arr = np.array([0, 1, 2], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=arr, offset=10, stride=5),), + ) + chunk_sel, _out_sel, drop_axes = sub_transform_to_selections(t) + assert isinstance(chunk_sel[0], np.ndarray) + np.testing.assert_array_equal(chunk_sel[0], np.array([10, 15, 20])) + assert drop_axes == () + + def test_mixed_maps_2d(self) -> None: + """Mix of ConstantMap and DimensionMap.""" + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=( + ConstantMap(offset=5), + DimensionMap(input_dimension=0, offset=0, stride=1), + ), + ) + chunk_sel, _out_sel, drop_axes = sub_transform_to_selections(t) + assert chunk_sel[0] == 5 + assert chunk_sel[1] == slice(0, 10, 1) + # drop_axes is empty — integer in chunk_sel naturally drops the dim via numpy + assert drop_axes == () + + +class TestChunkResolutionArrayMapFlavours: + """Chunk resolution must yield outer-product (np.ix_) selectors for + orthogonal ArrayMaps and shared flat-scatter selectors for correlated ones, + and must return early for empty fancy selections.""" + + def test_empty_array_selection_yields_nothing(self) -> None: + """An empty ArrayMap selection produces no chunk transforms (no crash).""" + t = IndexTransform( + domain=IndexDomain.from_shape((0,)), + output=(ArrayMap(index_array=np.array([], dtype=np.intp)),), + ) + grid = ChunkGrid(dimensions=(FixedDimension(size=3, extent=10),)) + assert list(iter_chunk_transforms(t, grid._dimensions)) == [] + + def test_orthogonal_outer_product_selectors(self) -> None: + """Two independent arrays produce np.ix_-style (mesh) chunk/out selectors.""" + t = IndexTransform.from_shape((10, 10)).oindex[np.array([1, 3]), np.array([2, 4, 6])] + grid = ChunkGrid( + dimensions=(FixedDimension(size=10, extent=10), FixedDimension(size=10, extent=10)) + ) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 1 + _coords, sub_t, out_indices = results[0] + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(sub_t, out_indices) + # np.ix_ produces one 2-D open-mesh selector per axis, for both sides. + assert len(chunk_sel) == 2 + assert len(out_sel) == 2 + assert isinstance(chunk_sel[0], np.ndarray) + assert isinstance(chunk_sel[1], np.ndarray) + assert chunk_sel[0].shape == (2, 1) + assert chunk_sel[1].shape == (1, 3) + assert drop_axes == () + + def test_correlated_scatter_with_residual_slice(self) -> None: + """Correlated arrays + a residual slice dim scatter through a single flat + index whose shape matches the (points, slice) block read from the chunk.""" + t = IndexTransform.from_shape((4, 3, 5)).vindex[np.array([1, 3]), np.array([2, 0])] + grid = ChunkGrid( + dimensions=( + FixedDimension(size=4, extent=4), + FixedDimension(size=3, extent=3), + FixedDimension(size=5, extent=5), + ) + ) + # One chunk holds everything: both points survive, slice dim spans [0,5). + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 1 + _coords, sub_t, out_indices = results[0] + chunk_sel, out_sel, _drop = sub_transform_to_selections(sub_t, out_indices) + # Chunk side: flat coordinate arrays for the two correlated dims plus a + # slice for the residual dim. + assert len(chunk_sel) == 3 + np.testing.assert_array_equal(np.asarray(chunk_sel[0]), [1, 3]) + np.testing.assert_array_equal(np.asarray(chunk_sel[1]), [2, 0]) + assert chunk_sel[2] == slice(0, 5, 1) + # Output side: a single flat scatter index of shape (points, slice) = (2, 5). + assert len(out_sel) == 1 + assert np.asarray(out_sel[0]).shape == (2, 5) diff --git a/packages/zarr-indexing/tests/test_composition.py b/packages/zarr-indexing/tests/test_composition.py new file mode 100644 index 0000000000..dd92f59b80 --- /dev/null +++ b/packages/zarr-indexing/tests/test_composition.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from zarr_indexing.composition import compose +from zarr_indexing.domain import IndexDomain +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform + + +class TestComposeConstantInner: + """Inner = constant. Result is always constant.""" + + def test_constant_inner_any_outer(self) -> None: + outer = IndexTransform.from_shape((5,)) + inner = IndexTransform( + domain=IndexDomain.from_shape((5,)), + output=(ConstantMap(offset=42),), + ) + result = compose(outer, inner) + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 42 + + +class TestComposeDimensionInner: + """Inner = DimensionMap.""" + + def test_dimension_inner_constant_outer(self) -> None: + outer = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(ConstantMap(offset=5),), + ) + inner = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(DimensionMap(input_dimension=0, offset=10, stride=3),), + ) + result = compose(outer, inner) + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 25 + + def test_dimension_inner_dimension_outer(self) -> None: + outer = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(DimensionMap(input_dimension=0, offset=5, stride=2),), + ) + inner = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(DimensionMap(input_dimension=0, offset=10, stride=3),), + ) + result = compose(outer, inner) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == 25 + assert result.output[0].stride == 6 + assert result.output[0].input_dimension == 0 + + def test_dimension_inner_array_outer(self) -> None: + arr = np.array([0, 2, 4], dtype=np.intp) + outer = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=arr, offset=5, stride=2),), + ) + inner = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(DimensionMap(input_dimension=0, offset=10, stride=3),), + ) + result = compose(outer, inner) + assert isinstance(result.output[0], ArrayMap) + assert result.output[0].offset == 25 + assert result.output[0].stride == 6 + np.testing.assert_array_equal(result.output[0].index_array, arr) + + +class TestComposeArrayInner: + """Inner = ArrayMap.""" + + def test_array_inner_constant_outer(self) -> None: + inner_arr = np.array([10, 20, 30], dtype=np.intp) + outer = IndexTransform( + domain=IndexDomain.from_shape((5,)), + output=(ConstantMap(offset=1),), + ) + inner = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=inner_arr, offset=0, stride=1),), + ) + result = compose(outer, inner) + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 20 + + def test_array_inner_array_outer(self) -> None: + outer_arr = np.array([0, 2, 1], dtype=np.intp) + inner_arr = np.array([10, 20, 30], dtype=np.intp) + outer = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=outer_arr, offset=0, stride=1),), + ) + inner = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=inner_arr, offset=0, stride=1),), + ) + result = compose(outer, inner) + assert isinstance(result.output[0], ArrayMap) + expected = np.array([10, 30, 20], dtype=np.intp) + np.testing.assert_array_equal(result.output[0].index_array, expected) + + +class TestComposeMultiDim: + def test_2d_identity_compose(self) -> None: + a = IndexTransform.from_shape((10, 20)) + b = IndexTransform.from_shape((10, 20)) + result = compose(a, b) + assert result.domain.shape == (10, 20) + for i in range(2): + m = result.output[i] + assert isinstance(m, DimensionMap) + assert m.input_dimension == i + assert m.offset == 0 + assert m.stride == 1 + + def test_mixed_map_types(self) -> None: + outer = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=( + ConstantMap(offset=5), + DimensionMap(input_dimension=0, offset=0, stride=1), + ), + ) + inner = IndexTransform( + domain=IndexDomain.from_shape((10, 10)), + output=( + DimensionMap(input_dimension=0, offset=2, stride=3), + DimensionMap(input_dimension=1, offset=0, stride=1), + ), + ) + result = compose(outer, inner) + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 17 + assert isinstance(result.output[1], DimensionMap) + assert result.output[1].input_dimension == 0 + assert result.output[1].offset == 0 + assert result.output[1].stride == 1 + + def test_rank_mismatch_raises(self) -> None: + outer = IndexTransform.from_shape((10,)) + inner = IndexTransform.from_shape((10, 20)) + with pytest.raises(ValueError, match="rank"): + compose(outer, inner) + + +class TestComposeChain: + def test_three_transforms(self) -> None: + a = IndexTransform.from_shape((100,)) + b = IndexTransform( + domain=IndexDomain.from_shape((100,)), + output=(DimensionMap(input_dimension=0, offset=10, stride=1),), + ) + c = IndexTransform( + domain=IndexDomain.from_shape((100,)), + output=(DimensionMap(input_dimension=0, offset=5, stride=2),), + ) + bc = compose(b, c) + abc = compose(a, bc) + assert isinstance(abc.output[0], DimensionMap) + assert abc.output[0].offset == 25 + assert abc.output[0].stride == 2 diff --git a/packages/zarr-indexing/tests/test_conformance.py b/packages/zarr-indexing/tests/test_conformance.py new file mode 100644 index 0000000000..207a9d8236 --- /dev/null +++ b/packages/zarr-indexing/tests/test_conformance.py @@ -0,0 +1,55 @@ +"""ndsel conformance corpus harness. + +Runs the vendored, language-agnostic ndsel fixtures (see +`tests/conformance/PROVENANCE.md`) against this package's message layer +(`zarr_indexing.messages`). An implementation is conformant iff: + +- for every *success* fixture, `normalize_ndsel(input)` equals the fixture's + `normalized` value by structural JSON equality; +- for every *error* fixture, `normalize_ndsel(input)` is rejected with an + `NdselError` carrying the fixture's `error` reason code. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from zarr_indexing.messages import NdselError, normalize_ndsel + +_CONFORMANCE_DIR = Path(__file__).parent / "conformance" + + +def _load_cases() -> list[tuple[str, dict[str, Any]]]: + cases: list[tuple[str, dict[str, Any]]] = [] + for path in sorted(_CONFORMANCE_DIR.glob("*.json")): + data = json.loads(path.read_text()) + cases.extend((f"{path.stem}::{case['name']}", case) for case in data) + return cases + + +_CASES = _load_cases() +_SUCCESS = [(name, c) for name, c in _CASES if "normalized" in c] +_ERROR = [(name, c) for name, c in _CASES if "error" in c] + + +def test_corpus_is_present() -> None: + # Guard against an empty/missing vendored corpus silently passing. + assert len(_SUCCESS) > 0 + assert len(_ERROR) > 0 + + +@pytest.mark.parametrize(("name", "case"), _SUCCESS, ids=[name for name, _ in _SUCCESS]) +def test_success_fixture(name: str, case: dict[str, Any]) -> None: + result = normalize_ndsel(case["input"]) + assert result == case["normalized"] + + +@pytest.mark.parametrize(("name", "case"), _ERROR, ids=[name for name, _ in _ERROR]) +def test_error_fixture(name: str, case: dict[str, Any]) -> None: + with pytest.raises(NdselError) as excinfo: + normalize_ndsel(case["input"]) + assert excinfo.value.reason == case["error"] diff --git a/packages/zarr-indexing/tests/test_domain.py b/packages/zarr-indexing/tests/test_domain.py new file mode 100644 index 0000000000..9664a0b08a --- /dev/null +++ b/packages/zarr-indexing/tests/test_domain.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import pytest + +from zarr_indexing.domain import IndexDomain + + +class TestIndexDomainConstruction: + def test_from_shape(self) -> None: + d = IndexDomain.from_shape((10, 20)) + assert d.inclusive_min == (0, 0) + assert d.exclusive_max == (10, 20) + assert d.ndim == 2 + assert d.origin == (0, 0) + assert d.shape == (10, 20) + + def test_from_shape_0d(self) -> None: + d = IndexDomain.from_shape(()) + assert d.ndim == 0 + assert d.shape == () + + def test_non_zero_origin(self) -> None: + d = IndexDomain(inclusive_min=(5, 10), exclusive_max=(15, 30)) + assert d.origin == (5, 10) + assert d.shape == (10, 20) + assert d.ndim == 2 + + def test_validation_mismatched_lengths(self) -> None: + with pytest.raises(ValueError, match="same length"): + IndexDomain(inclusive_min=(0,), exclusive_max=(10, 20)) + + def test_validation_min_greater_than_max(self) -> None: + with pytest.raises(ValueError, match="inclusive_min must be <="): + IndexDomain(inclusive_min=(10,), exclusive_max=(5,)) + + def test_empty_domain(self) -> None: + d = IndexDomain(inclusive_min=(5,), exclusive_max=(5,)) + assert d.shape == (0,) + + def test_labels(self) -> None: + d = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 20), labels=("x", "y")) + assert d.labels == ("x", "y") + + def test_labels_none(self) -> None: + d = IndexDomain.from_shape((10,)) + assert d.labels is None + + +class TestIndexDomainContains: + def test_contains_inside(self) -> None: + d = IndexDomain.from_shape((10, 20)) + assert d.contains((0, 0)) is True + assert d.contains((9, 19)) is True + assert d.contains((5, 10)) is True + + def test_contains_outside(self) -> None: + d = IndexDomain.from_shape((10, 20)) + assert d.contains((10, 0)) is False + assert d.contains((-1, 0)) is False + assert d.contains((0, 20)) is False + + def test_contains_non_zero_origin(self) -> None: + d = IndexDomain(inclusive_min=(5,), exclusive_max=(10,)) + assert d.contains((5,)) is True + assert d.contains((9,)) is True + assert d.contains((4,)) is False + assert d.contains((10,)) is False + + def test_contains_wrong_ndim(self) -> None: + d = IndexDomain.from_shape((10, 20)) + assert d.contains((5,)) is False + + def test_contains_domain_inside(self) -> None: + outer = IndexDomain.from_shape((10, 20)) + inner = IndexDomain(inclusive_min=(2, 3), exclusive_max=(8, 15)) + assert outer.contains_domain(inner) is True + + def test_contains_domain_outside(self) -> None: + outer = IndexDomain.from_shape((10, 20)) + inner = IndexDomain(inclusive_min=(2, 3), exclusive_max=(11, 15)) + assert outer.contains_domain(inner) is False + + def test_contains_domain_wrong_ndim(self) -> None: + outer = IndexDomain.from_shape((10, 20)) + inner = IndexDomain.from_shape((5,)) + assert outer.contains_domain(inner) is False + + +class TestIndexDomainIntersect: + def test_overlapping(self) -> None: + a = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 10)) + b = IndexDomain(inclusive_min=(5, 5), exclusive_max=(15, 15)) + result = a.intersect(b) + assert result is not None + assert result.inclusive_min == (5, 5) + assert result.exclusive_max == (10, 10) + + def test_disjoint(self) -> None: + a = IndexDomain(inclusive_min=(0,), exclusive_max=(5,)) + b = IndexDomain(inclusive_min=(10,), exclusive_max=(15,)) + assert a.intersect(b) is None + + def test_touching_boundary(self) -> None: + a = IndexDomain(inclusive_min=(0,), exclusive_max=(5,)) + b = IndexDomain(inclusive_min=(5,), exclusive_max=(10,)) + assert a.intersect(b) is None + + def test_contained(self) -> None: + a = IndexDomain.from_shape((20,)) + b = IndexDomain(inclusive_min=(5,), exclusive_max=(10,)) + result = a.intersect(b) + assert result is not None + assert result.inclusive_min == (5,) + assert result.exclusive_max == (10,) + + def test_wrong_ndim(self) -> None: + a = IndexDomain.from_shape((10,)) + b = IndexDomain.from_shape((10, 20)) + with pytest.raises(ValueError, match="different ranks"): + a.intersect(b) + + +class TestIndexDomainTranslate: + def test_translate_positive(self) -> None: + d = IndexDomain.from_shape((10, 20)) + result = d.translate((5, 10)) + assert result.inclusive_min == (5, 10) + assert result.exclusive_max == (15, 30) + + def test_translate_negative(self) -> None: + d = IndexDomain(inclusive_min=(10, 20), exclusive_max=(30, 40)) + result = d.translate((-10, -20)) + assert result.inclusive_min == (0, 0) + assert result.exclusive_max == (20, 20) + + def test_translate_wrong_length(self) -> None: + d = IndexDomain.from_shape((10,)) + with pytest.raises(ValueError, match="same length"): + d.translate((1, 2)) + + +class TestIndexDomainNarrow: + def test_narrow_slice(self) -> None: + d = IndexDomain.from_shape((10, 20)) + result = d.narrow((slice(2, 8), slice(5, 15))) + assert result.inclusive_min == (2, 5) + assert result.exclusive_max == (8, 15) + + def test_narrow_int(self) -> None: + d = IndexDomain.from_shape((10, 20)) + result = d.narrow((3, slice(None))) + assert result.inclusive_min == (3, 0) + assert result.exclusive_max == (4, 20) + + def test_narrow_ellipsis(self) -> None: + d = IndexDomain.from_shape((10, 20, 30)) + result = d.narrow((slice(1, 5), ...)) + assert result.inclusive_min == (1, 0, 0) + assert result.exclusive_max == (5, 20, 30) + + def test_narrow_slice_none(self) -> None: + d = IndexDomain.from_shape((10,)) + result = d.narrow((slice(None),)) + assert result == d + + def test_narrow_non_zero_origin(self) -> None: + d = IndexDomain(inclusive_min=(10,), exclusive_max=(20,)) + result = d.narrow((slice(12, 18),)) + assert result.inclusive_min == (12,) + assert result.exclusive_max == (18,) + + def test_narrow_int_out_of_bounds(self) -> None: + d = IndexDomain.from_shape((10,)) + with pytest.raises(IndexError, match="out of bounds"): + d.narrow((10,)) + + def test_narrow_int_below_origin(self) -> None: + d = IndexDomain(inclusive_min=(5,), exclusive_max=(10,)) + with pytest.raises(IndexError, match="out of bounds"): + d.narrow((4,)) + + def test_narrow_clamps_to_domain(self) -> None: + d = IndexDomain.from_shape((10,)) + result = d.narrow((slice(-5, 100),)) + assert result.inclusive_min == (0,) + assert result.exclusive_max == (10,) + + def test_narrow_bare_slice(self) -> None: + d = IndexDomain.from_shape((10,)) + result = d.narrow(slice(2, 8)) + assert result.inclusive_min == (2,) + assert result.exclusive_max == (8,) + + def test_narrow_too_many_indices(self) -> None: + d = IndexDomain.from_shape((10,)) + with pytest.raises(IndexError, match="too many indices"): + d.narrow((1, 2)) + + def test_narrow_step_not_one(self) -> None: + d = IndexDomain.from_shape((10,)) + with pytest.raises(IndexError, match="step=1"): + d.narrow((slice(0, 10, 2),)) diff --git a/packages/zarr-indexing/tests/test_json.py b/packages/zarr-indexing/tests/test_json.py new file mode 100644 index 0000000000..42b59b2c30 --- /dev/null +++ b/packages/zarr-indexing/tests/test_json.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.json import ( + IndexTransformJSON, + index_domain_from_json, + index_domain_to_json, + index_transform_from_json, + index_transform_to_json, + output_index_map_from_json, + output_index_map_to_json, +) +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform + + +def _maps_equal(a: object, b: object) -> bool: + if type(a) is not type(b): + return False + if isinstance(a, ConstantMap): + assert isinstance(b, ConstantMap) + return a.offset == b.offset + if isinstance(a, DimensionMap): + assert isinstance(b, DimensionMap) + return (a.input_dimension, a.offset, a.stride) == (b.input_dimension, b.offset, b.stride) + assert isinstance(a, ArrayMap) + assert isinstance(b, ArrayMap) + return ( + a.offset == b.offset + and a.stride == b.stride + and a.input_dimension == b.input_dimension + and np.array_equal(a.index_array, b.index_array) + ) + + +def _transforms_equal(a: IndexTransform, b: IndexTransform) -> bool: + """Structural equality that compares `ArrayMap` index arrays element-wise + (`IndexTransform`'s dataclass `__eq__` cannot, as numpy `==` is ambiguous).""" + return ( + a.domain == b.domain + and len(a.output) == len(b.output) + and all(_maps_equal(x, y) for x, y in zip(a.output, b.output, strict=True)) + ) + + +class TestIndexDomainJSON: + def test_roundtrip(self) -> None: + domain = IndexDomain(inclusive_min=(2, 5), exclusive_max=(10, 20)) + json = index_domain_to_json(domain) + assert json == { + "input_inclusive_min": [2, 5], + "input_exclusive_max": [10, 20], + "input_labels": ["", ""], + } + restored = index_domain_from_json(json) + assert restored == domain + + def test_with_labels(self) -> None: + domain = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 20), labels=("x", "y")) + json = index_domain_to_json(domain) + assert json["input_labels"] == ["x", "y"] + restored = index_domain_from_json(json) + assert restored.labels == ("x", "y") + + def test_without_labels_emits_empty_and_round_trips_to_none(self) -> None: + domain = IndexDomain.from_shape((5,)) + json = index_domain_to_json(domain) + # Canonical form always writes labels; an unlabeled domain gets [""]*rank. + assert json["input_labels"] == [""] + restored = index_domain_from_json(json) + assert restored.labels is None + + def test_zero_origin(self) -> None: + domain = IndexDomain.from_shape((10, 20, 30)) + json = index_domain_to_json(domain) + assert json == { + "input_inclusive_min": [0, 0, 0], + "input_exclusive_max": [10, 20, 30], + "input_labels": ["", "", ""], + } + assert index_domain_from_json(json) == domain + + +class TestOutputIndexMapJSON: + def test_constant(self) -> None: + m = ConstantMap(offset=42) + json = output_index_map_to_json(m) + assert json == {"offset": 42} + restored = output_index_map_from_json(json) + assert isinstance(restored, ConstantMap) + assert restored.offset == 42 + + def test_constant_zero(self) -> None: + m = ConstantMap(offset=0) + json = output_index_map_to_json(m) + assert json == {"offset": 0} + restored = output_index_map_from_json(json) + assert isinstance(restored, ConstantMap) + assert restored.offset == 0 + + def test_dimension(self) -> None: + m = DimensionMap(input_dimension=1, offset=10, stride=3) + json = output_index_map_to_json(m) + assert json == {"offset": 10, "stride": 3, "input_dimension": 1} + restored = output_index_map_from_json(json) + assert isinstance(restored, DimensionMap) + assert restored.input_dimension == 1 + assert restored.offset == 10 + assert restored.stride == 3 + + def test_dimension_stride_1_written(self) -> None: + """Canonical form writes stride even at its default of 1.""" + m = DimensionMap(input_dimension=0) + json = output_index_map_to_json(m) + assert json == {"offset": 0, "stride": 1, "input_dimension": 0} + restored = output_index_map_from_json(json) + assert isinstance(restored, DimensionMap) + assert restored.stride == 1 + + def test_array(self) -> None: + arr = np.array([1, 5, 9], dtype=np.intp) + m = ArrayMap(index_array=arr, offset=2, stride=3) + json = output_index_map_to_json(m) + # Canonical: stride/offset present, index_array_bounds present, and + # no input_dimension (ndsel/TensorStore reject it beside index_array). + assert json == { + "offset": 2, + "stride": 3, + "index_array": [1, 5, 9], + "index_array_bounds": ["-inf", "+inf"], + } + restored = output_index_map_from_json(json) + assert isinstance(restored, ArrayMap) + np.testing.assert_array_equal(restored.index_array, arr) + assert restored.offset == 2 + assert restored.stride == 3 + + def test_array_stride_1_written(self) -> None: + arr = np.array([0, 1, 2], dtype=np.intp) + m = ArrayMap(index_array=arr) + json = output_index_map_to_json(m) + assert json["stride"] == 1 + restored = output_index_map_from_json(json) + assert isinstance(restored, ArrayMap) + assert restored.stride == 1 + + def test_array_2d(self) -> None: + arr = np.array([[1, 2], [3, 4]], dtype=np.intp) + m = ArrayMap(index_array=arr) + json = output_index_map_to_json(m) + assert json["index_array"] == [[1, 2], [3, 4]] + restored = output_index_map_from_json(json) + assert isinstance(restored, ArrayMap) + np.testing.assert_array_equal(restored.index_array, arr) + + def test_degenerate_singleton_array_collapses_to_constant(self) -> None: + """An all-singleton index_array selects one coordinate -> constant map.""" + m = ArrayMap(index_array=np.array([[4]], dtype=np.intp), offset=1, stride=2) + json = output_index_map_to_json(m) + assert json == {"offset": 1 + 2 * 4} + restored = output_index_map_from_json(json) + assert isinstance(restored, ConstantMap) + assert restored.offset == 9 + + +class TestIndexTransformJSON: + def test_identity(self) -> None: + t = IndexTransform.from_shape((10, 20)) + json = index_transform_to_json(t) + assert json == { + "input_rank": 2, + "input_inclusive_min": [0, 0], + "input_exclusive_max": [10, 20], + "input_labels": ["", ""], + "output": [ + {"offset": 0, "stride": 1, "input_dimension": 0}, + {"offset": 0, "stride": 1, "input_dimension": 1}, + ], + } + restored = index_transform_from_json(json) + assert restored.domain == t.domain + assert len(restored.output) == 2 + for orig, rest in zip(t.output, restored.output, strict=True): + assert type(orig) is type(rest) + + def test_sliced(self) -> None: + t = IndexTransform.from_shape((100,))[10:50:2] + json = index_transform_to_json(t) + restored = index_transform_from_json(json) + assert restored.domain.shape == t.domain.shape + assert isinstance(restored.output[0], DimensionMap) + orig = t.output[0] + assert isinstance(orig, DimensionMap) + assert restored.output[0].offset == orig.offset + assert restored.output[0].stride == orig.stride + + def test_with_constant(self) -> None: + t = IndexTransform.from_shape((10, 20))[3] + json = index_transform_to_json(t) + restored = index_transform_from_json(json) + assert isinstance(restored.output[0], ConstantMap) + assert restored.output[0].offset == 3 + assert isinstance(restored.output[1], DimensionMap) + + def test_with_array(self) -> None: + idx = np.array([1, 5, 9], dtype=np.intp) + t = IndexTransform.from_shape((10, 20)).oindex[idx, :] + json = index_transform_to_json(t) + # The oindex array must not carry input_dimension on the wire. + assert "input_dimension" not in json["output"][0] + restored = index_transform_from_json(json) + assert isinstance(restored.output[0], ArrayMap) + # Orthogonal arrays are normalized to full input rank with a singleton + # axis on the dimension they do not vary over. + assert restored.output[0].index_array.shape == (3, 1) + np.testing.assert_array_equal(restored.output[0].index_array, idx.reshape(3, 1)) + # input_dimension is reconstructed from the sole non-singleton axis. + assert restored.output[0].input_dimension == 0 + assert isinstance(restored.output[1], DimensionMap) + + def test_roundtrip_preserves_singleton_axes(self) -> None: + """Full-rank orthogonal arrays keep their singleton axes across JSON.""" + t = IndexTransform.from_shape((10, 20)).oindex[np.array([1, 3]), np.array([2, 4, 6])] + restored = index_transform_from_json(index_transform_to_json(t)) + orig0, orig1 = t.output[0], t.output[1] + rest0, rest1 = restored.output[0], restored.output[1] + assert isinstance(orig0, ArrayMap) + assert isinstance(orig1, ArrayMap) + assert isinstance(rest0, ArrayMap) + assert isinstance(rest1, ArrayMap) + assert rest0.index_array.shape == (2, 1) + assert rest1.index_array.shape == (1, 3) + np.testing.assert_array_equal(rest0.index_array, orig0.index_array) + np.testing.assert_array_equal(rest1.index_array, orig1.index_array) + # Distinct, exclusively-owned axes -> reconstructed as orthogonal. + assert rest0.input_dimension == 0 + assert rest1.input_dimension == 1 + + def test_with_labels(self) -> None: + domain = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 20), labels=("x", "y")) + t = IndexTransform.identity(domain) + json = index_transform_to_json(t) + assert json["input_labels"] == ["x", "y"] + restored = index_transform_from_json(json) + assert restored.domain.labels == ("x", "y") + + def test_tensorstore_compatible_format(self) -> None: + """A canonical body loads and round-trips through the engine layer.""" + json: IndexTransformJSON = { + "input_rank": 3, + "input_inclusive_min": [0, 0, 0], + "input_exclusive_max": [100, 200, 3], + "input_labels": ["x", "y", "channel"], + "output": [ + {"offset": 5}, + {"offset": 10, "stride": 2, "input_dimension": 1}, + {"offset": 0, "stride": 1, "index_array": [1, 2, 0]}, + ], + } + t = index_transform_from_json(json) + assert t.domain.shape == (100, 200, 3) + assert t.domain.labels == ("x", "y", "channel") + assert isinstance(t.output[0], ConstantMap) + assert t.output[0].offset == 5 + assert isinstance(t.output[1], DimensionMap) + assert t.output[1].offset == 10 + assert t.output[1].stride == 2 + assert t.output[1].input_dimension == 1 + assert isinstance(t.output[2], ArrayMap) + np.testing.assert_array_equal(t.output[2].index_array, [1, 2, 0]) + + # Roundtrip + json_rt = index_transform_to_json(t) + t_rt = index_transform_from_json(json_rt) + assert t_rt.domain == t.domain + + +class TestCanonicalRoundTrips: + """Round-trip `transform == from(to(transform))`, up to the documented + degenerate-collapse (all-singleton ArrayMap -> ConstantMap).""" + + def test_oindex_multi_axis(self) -> None: + t = IndexTransform.from_shape((10, 20, 30)).oindex[np.array([1, 3]), :, np.array([2, 4, 6])] + rt = index_transform_from_json(index_transform_to_json(t)) + assert _transforms_equal(rt, t) + + def test_oindex_with_slice(self) -> None: + t = IndexTransform.from_shape((10, 20))[2:8].oindex[np.array([3, 5, 7]), :] + rt = index_transform_from_json(index_transform_to_json(t)) + assert _transforms_equal(rt, t) + + def test_vindex(self) -> None: + t = IndexTransform.from_shape((10, 20)).vindex[np.array([1, 3, 5]), np.array([2, 4, 6])] + rt = index_transform_from_json(index_transform_to_json(t)) + assert _transforms_equal(rt, t) + + def test_vindex_with_residual_slice(self) -> None: + t = IndexTransform.from_shape((10, 20, 30)).vindex[np.array([1, 3]), np.array([2, 4]), :] + rt = index_transform_from_json(index_transform_to_json(t)) + assert _transforms_equal(rt, t) + + def test_length1_degenerate_oindex_collapses(self) -> None: + """A length-1 oindex array becomes an all-singleton ArrayMap; the JSON + round-trip collapses it to a ConstantMap (behaviorally identical).""" + t = IndexTransform.from_shape((10, 20)).oindex[np.array([7]), :] + m = t.output[0] + assert isinstance(m, ArrayMap) + assert m.index_array.size == 1 + + rt = index_transform_from_json(index_transform_to_json(t)) + # The degenerate array collapsed to a constant selecting the same cell. + rm = rt.output[0] + assert isinstance(rm, ConstantMap) + assert rm.offset == 7 + # The size-1 input dimension survives, unconsumed, in the domain. + assert rt.domain == t.domain + + def test_slices_and_constants(self) -> None: + t = IndexTransform.from_shape((10, 20, 30))[2:8:2, 5, :] + rt = index_transform_from_json(index_transform_to_json(t)) + assert _transforms_equal(rt, t) + + +def test_infinite_bound_rejected_on_lowering() -> None: + body: IndexTransformJSON = { + "input_rank": 1, + "input_inclusive_min": [0], + "input_exclusive_max": [["+inf"]], + "input_labels": [""], + "output": [{"offset": 0, "stride": 1, "input_dimension": 0}], + } + with pytest.raises(ValueError, match="infinite"): + index_transform_from_json(body) diff --git a/packages/zarr-indexing/tests/test_messages.py b/packages/zarr-indexing/tests/test_messages.py new file mode 100644 index 0000000000..14bed66448 --- /dev/null +++ b/packages/zarr-indexing/tests/test_messages.py @@ -0,0 +1,91 @@ +"""Message-layer tests beyond the vendored conformance corpus. + +The corpus (see `test_conformance.py`) covers the desugaring matrix and error +codes. These tests pin behaviors the corpus does not: `normalize` idempotence, +`parse_ndsel`, 64-bit boundary handling, and schema-valid-but-redundant maps. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from zarr_indexing.messages import NdselError, normalize_ndsel, parse_ndsel + +_MESSAGES = [ + {"kind": "point", "coords": [4, 7]}, + {"kind": "box", "inclusive_min": [0, 0], "exclusive_max": [3, 4]}, + {"kind": "box", "inclusive_min": [["-inf"], 0], "exclusive_max": [["+inf"], 4]}, + {"kind": "slice", "start": [5], "stop": [10], "step": [2]}, + {"kind": "points", "coords": [[1, 10], [2, 20]]}, + { + "kind": "transform", + "input_inclusive_min": [0], + "input_exclusive_max": [3], + "output": [{"offset": 7}, {"input_dimension": 0, "stride": 2}, {"index_array": [1, 2, 3]}], + }, +] + + +@pytest.mark.parametrize("message", _MESSAGES) +def test_normalize_is_idempotent(message: dict[str, Any]) -> None: + once = normalize_ndsel(message) + twice = normalize_ndsel({"kind": "transform", **once}) + assert twice == once + + +@pytest.mark.parametrize("message", _MESSAGES) +def test_parse_returns_message_unchanged(message: dict[str, Any]) -> None: + assert parse_ndsel(message) == message + + +def test_parse_rejects_invalid() -> None: + with pytest.raises(NdselError) as excinfo: + parse_ndsel({"kind": "slice", "start": [0]}) + assert excinfo.value.reason == "invalid_json" + + +def test_constant_map_drops_redundant_stride() -> None: + # A constant map (no input_dimension, no index_array) is schema-valid even + # with a stray stride; it canonicalizes to offset-only. + result = normalize_ndsel( + {"kind": "transform", "input_rank": 0, "output": [{"offset": 5, "stride": 9}]} + ) + assert result["output"] == [{"offset": 5}] + + +def test_i64_min_and_max_round_trip() -> None: + i64_min, i64_max = -(2**63), 2**63 - 1 + result = normalize_ndsel({"kind": "point", "coords": [i64_min, i64_max]}) + assert result["output"] == [{"offset": i64_min}, {"offset": i64_max}] + + +def test_i64_overflow_rejected() -> None: + with pytest.raises(NdselError) as excinfo: + normalize_ndsel({"kind": "point", "coords": [2**63]}) + assert excinfo.value.reason == "invalid_json" + + +def test_bool_in_output_offset_rejected() -> None: + with pytest.raises(NdselError) as excinfo: + normalize_ndsel({"kind": "transform", "input_rank": 0, "output": [{"offset": True}]}) + assert excinfo.value.reason == "invalid_json" + + +def test_sentinel_not_allowed_in_plain_integer_position() -> None: + with pytest.raises(NdselError) as excinfo: + normalize_ndsel({"kind": "point", "coords": ["+inf"]}) + assert excinfo.value.reason == "invalid_json" + + +def test_not_an_object_rejected() -> None: + with pytest.raises(NdselError) as excinfo: + normalize_ndsel([1, 2, 3]) + assert excinfo.value.reason == "invalid_json" + + +def test_empty_string_kind_is_unknown_kind() -> None: + with pytest.raises(NdselError) as excinfo: + normalize_ndsel({"kind": ""}) + assert excinfo.value.reason == "unknown_kind" diff --git a/packages/zarr-indexing/tests/test_ndsel_tensorstore.py b/packages/zarr-indexing/tests/test_ndsel_tensorstore.py new file mode 100644 index 0000000000..794ac5f3d5 --- /dev/null +++ b/packages/zarr-indexing/tests/test_ndsel_tensorstore.py @@ -0,0 +1,52 @@ +"""Cross-check canonical ndsel bodies against a real TensorStore. + +A normalized ndsel `transform` body is, field-for-field, a TensorStore +`IndexTransform` (minus the `kind` discriminator, which the canonical body never +carries). This test loads a handful of finite-bound canonical bodies into +`tensorstore.IndexTransform(json=...)` and confirms that TensorStore's own +`to_json()` re-loads, through our engine layer, into an equivalent transform. + +Skipped when tensorstore is not installed. Run it explicitly with: + + uv run --with tensorstore pytest \ + packages/zarr-indexing/tests/test_ndsel_tensorstore.py -q +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from zarr_indexing.json import transform_from_canonical, transform_to_canonical +from zarr_indexing.transform import IndexTransform + +ts = pytest.importorskip("tensorstore") + + +def _canonical_transforms() -> list[IndexTransform]: + base = IndexTransform.from_shape((10, 20)) + return [ + base, # identity + base[2:8:2, :], # strided DimensionMap + identity + base[3, :], # integer index -> ConstantMap + DimensionMap + base.oindex[np.array([1, 5, 9]), :], # orthogonal index_array + IndexTransform.from_shape((10, 20, 30)).vindex[ + np.array([1, 3]), np.array([2, 4]), : + ], # correlated index_arrays + residual slice + ] + + +@pytest.mark.parametrize("transform", _canonical_transforms()) +def test_body_loads_in_tensorstore_and_round_trips(transform: IndexTransform) -> None: + body = transform_to_canonical(transform) + + # (1) The canonical body loads directly as a TensorStore IndexTransform. + ts_transform = ts.IndexTransform(json=body) + + # (2) TensorStore's own JSON re-loads, through our engine, to an equivalent + # transform. Comparing via our canonical form normalizes away + # representational choices (index_array_bounds, default omissions) that + # both sides make differently but that denote the same selection. + ts_json = ts_transform.to_json() + reloaded = transform_from_canonical(ts_json) + assert transform_to_canonical(reloaded) == transform_to_canonical(transform) diff --git a/packages/zarr-indexing/tests/test_output_map.py b/packages/zarr-indexing/tests/test_output_map.py new file mode 100644 index 0000000000..498101444e --- /dev/null +++ b/packages/zarr-indexing/tests/test_output_map.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import numpy as np + +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap + + +class TestConstantMap: + def test_construction(self) -> None: + m = ConstantMap(offset=42) + assert m.offset == 42 + + def test_default_offset(self) -> None: + m = ConstantMap() + assert m.offset == 0 + + def test_frozen(self) -> None: + m = ConstantMap(offset=5) + assert isinstance(m, ConstantMap) + + +class TestDimensionMap: + def test_construction(self) -> None: + m = DimensionMap(input_dimension=3, offset=5, stride=2) + assert m.input_dimension == 3 + assert m.offset == 5 + assert m.stride == 2 + + def test_defaults(self) -> None: + m = DimensionMap(input_dimension=0) + assert m.offset == 0 + assert m.stride == 1 + + def test_frozen(self) -> None: + m = DimensionMap(input_dimension=0) + assert isinstance(m, DimensionMap) + + +class TestArrayMap: + def test_construction(self) -> None: + arr = np.array([1, 3, 5], dtype=np.intp) + m = ArrayMap(index_array=arr, offset=10, stride=2) + assert m.offset == 10 + assert m.stride == 2 + np.testing.assert_array_equal(m.index_array, arr) + + def test_defaults(self) -> None: + arr = np.array([0, 1], dtype=np.intp) + m = ArrayMap(index_array=arr) + assert m.offset == 0 + assert m.stride == 1 + + def test_frozen(self) -> None: + arr = np.array([0], dtype=np.intp) + m = ArrayMap(index_array=arr) + assert isinstance(m, ArrayMap) diff --git a/packages/zarr-indexing/tests/test_tensorstore_parity.py b/packages/zarr-indexing/tests/test_tensorstore_parity.py new file mode 100644 index 0000000000..1ed99046e9 --- /dev/null +++ b/packages/zarr-indexing/tests/test_tensorstore_parity.py @@ -0,0 +1,263 @@ +"""TensorStore-parity oracle tests for IndexTransform semantics. + +Every case in this module was executed against tensorstore 0.1.84 (see the +lazy-indexing design notes): the expected domains, values, and error conditions +are TensorStore's observed behavior, which zarr's lazy indexing matches by +design. Core rules pinned here: + +- **Domain preservation**: a step-1 slice keeps the literal coordinates of the + selected interval (`a[2:10]` has domain `[2, 10)`); nothing re-zeros + implicitly. Re-zeroing is explicit via `translate_to`. +- **Strided-domain rule**: for step ``k``, ``origin = trunc(start/k)`` (rounded + toward zero), ``shape = ceil((stop - start)/k)``, and coordinate + ``origin + i`` maps to base cell ``start + i*k``. +- **Strict containment**: non-empty slice intervals must lie within the domain + — no clamping, no negative-wrapping; empty intervals are valid anywhere; + reversed non-empty bounds are an error, not an empty result. +- **Fancy-dim rule**: index-array dims get fresh explicit ``[0, n)`` domains; + index-array values are absolute domain coordinates. +- **Translate rules**: ``translate_by``/``translate_to`` shift the input domain + while preserving which cells are addressed. +""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np +import pytest + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.errors import BoundsCheckError +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform + + +def _identity(lo: int, hi: int) -> IndexTransform: + """Identity transform over the 1-D domain [lo, hi).""" + return IndexTransform.identity(IndexDomain(inclusive_min=(lo,), exclusive_max=(hi,))) + + +def _a() -> IndexTransform: + """The oracle's base fixture: identity over [0, 12).""" + return _identity(0, 12) + + +def _w() -> IndexTransform: + """The oracle's translated fixture: identity over [-10, 2), cell c -> base c + 10.""" + return _a().translate_domain_by((-10,)) + + +def _dim(t: IndexTransform) -> DimensionMap: + m = t.output[0] + assert isinstance(m, DimensionMap) + return m + + +def _base_cells(t: IndexTransform) -> list[int]: + """The base cells a 1-D single-DimensionMap transform addresses, in order.""" + m = _dim(t) + lo, hi = t.domain.inclusive_min[0], t.domain.exclusive_max[0] + return [m.offset + m.stride * c for c in range(lo, hi)] + + +class TestDomainPreservation: + """Oracle section 1-2: step-1 slices keep literal coordinates.""" + + def test_slice_preserves_domain(self) -> None: + t = _a()[2:10] + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((2,), (10,)) + assert _base_cells(t) == list(range(2, 10)) + + def test_integer_on_preserved_domain_is_a_coordinate(self) -> None: + v = _a()[2:10] + assert isinstance(v[3].output[0], ConstantMap) + assert v[3].output[0].offset == 3 # coordinate 3 = base cell 3 + assert v[2].output[0].offset == 2 + assert v[9].output[0].offset == 9 + + @pytest.mark.parametrize("bad", [0, -1, 10]) + def test_out_of_domain_integer_raises(self, bad: int) -> None: + with pytest.raises(BoundsCheckError, match=r"valid indices \[2, 10\)"): + _a()[2:10][bad] + + def test_slice_of_slice_is_literal(self) -> None: + v = _a()[2:10] + t = v[3:7] + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((3,), (7,)) + assert _base_cells(t) == [3, 4, 5, 6] + + def test_ellipsis_preserves_domain(self) -> None: + v = _a()[2:10] + t = v[...] + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((2,), (10,)) + + +class TestNegativeOriginDomain: + """Oracle section 3: on domain [-10, 2), -1 is just another index.""" + + def test_translated_domain(self) -> None: + w = _w() + assert (w.domain.inclusive_min, w.domain.exclusive_max) == ((-10,), (2,)) + assert _base_cells(w) == list(range(12)) + + @pytest.mark.parametrize(("coord", "base"), [(-5, 5), (-10, 0), (-1, 9), (1, 11)]) + def test_negative_coordinates_address_cells(self, coord: int, base: int) -> None: + t = _w()[coord] + assert isinstance(t.output[0], ConstantMap) + assert t.output[0].offset == base + + @pytest.mark.parametrize("bad", [-11, 2]) + def test_out_of_domain_raises(self, bad: int) -> None: + with pytest.raises(BoundsCheckError, match=r"valid indices \[-10, 2\)"): + _w()[bad] + + def test_negative_slice_bounds_are_coordinates(self) -> None: + t = _w()[-5:] + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((-5,), (2,)) + assert _base_cells(t) == [5, 6, 7, 8, 9, 10, 11] + t2 = _w()[-5:-2] + assert (t2.domain.inclusive_min, t2.domain.exclusive_max) == ((-5,), (-2,)) + assert _base_cells(t2) == [5, 6, 7] + + +class TestStridedDomains: + """Oracle section 5: origin = trunc(start/step), coord origin+i -> start + i*step.""" + + # (slice, expected (lo, hi), expected base cells) — verbatim oracle rows. + CASES: ClassVar[list[tuple[slice, tuple[int, int], list[int]]]] = [ + (slice(1, 10, 3), (0, 3), [1, 4, 7]), + (slice(None, None, 2), (0, 6), [0, 2, 4, 6, 8, 10]), + (slice(2, 11, 3), (0, 3), [2, 5, 8]), + (slice(0, 12, 4), (0, 3), [0, 4, 8]), + (slice(5, 12, 2), (2, 6), [5, 7, 9, 11]), + (slice(6, 12, 2), (3, 6), [6, 8, 10]), + (slice(7, 12, 3), (2, 4), [7, 10]), + ] + + @pytest.mark.parametrize(("sel", "dom", "cells"), CASES) + def test_strided_domain_and_cells( + self, sel: slice, dom: tuple[int, int], cells: list[int] + ) -> None: + t = _a()[sel] + assert (t.domain.inclusive_min[0], t.domain.exclusive_max[0]) == dom + assert _base_cells(t) == cells + + def test_strided_on_negative_origin(self) -> None: + # w[-9:2:2] -> domain [-4, 2), base cells 1,3,5,7,9,11 + t = _w()[-9:2:2] + assert (t.domain.inclusive_min[0], t.domain.exclusive_max[0]) == (-4, 2) + assert _base_cells(t) == [1, 3, 5, 7, 9, 11] + # w[::2] -> domain [-5, 1), base cells 0,2,4,6,8,10 + t2 = _w()[::2] + assert (t2.domain.inclusive_min[0], t2.domain.exclusive_max[0]) == (-5, 1) + assert _base_cells(t2) == [0, 2, 4, 6, 8, 10] + + def test_strided_composition(self) -> None: + s = _a()[1:10:3] # domain [0, 3), cells 1,4,7 + assert [s[k].output[0].offset for k in range(3)] == [1, 4, 7] + t = s[1:3] + assert (t.domain.inclusive_min[0], t.domain.exclusive_max[0]) == (1, 3) + assert _base_cells(t) == [4, 7] + t2 = _a()[::2][1:4] + assert (t2.domain.inclusive_min[0], t2.domain.exclusive_max[0]) == (1, 4) + assert _base_cells(t2) == [2, 4, 6] + t3 = _a()[::2][::2] + assert (t3.domain.inclusive_min[0], t3.domain.exclusive_max[0]) == (0, 3) + assert _base_cells(t3) == [0, 4, 8] + + @pytest.mark.parametrize("bad", [-2, -1, 3, 4]) + def test_strided_bounds(self, bad: int) -> None: + with pytest.raises(BoundsCheckError, match=r"valid indices \[0, 3\)"): + _a()[1:10:3][bad] + + +class TestStrictContainment: + """Oracle section 11: no clamping, no wrapping; empty intervals valid anywhere.""" + + @pytest.mark.parametrize( + "sel", + [ + slice(5, 100), + slice(-3, None), + slice(-3, -1), + slice(0, 13), + slice(12, 14), + slice(100, 200), + ], + ) + def test_uncontained_interval_raises(self, sel: slice) -> None: + with pytest.raises(BoundsCheckError, match="not contained"): + _a()[sel] + + def test_uncontained_on_negative_origin(self) -> None: + with pytest.raises(BoundsCheckError, match="not contained"): + _w()[-20:] + + @pytest.mark.parametrize( + ("sel", "pos"), [(slice(5, 5), 5), (slice(0, 0), 0), (slice(13, 13), 13)] + ) + def test_empty_interval_valid_anywhere(self, sel: slice, pos: int) -> None: + t = _a()[sel] + assert t.domain.shape == (0,) + assert t.domain.inclusive_min[0] == pos + + @pytest.mark.parametrize("sel", [slice(5, 2), slice(100, 50)]) + def test_reversed_bounds_raise(self, sel: slice) -> None: + with pytest.raises(IndexError, match="valid.*interval|interval"): + _a()[sel] + + +class TestTranslate: + """Oracle sections 4 and 12: translate_by / translate_to preserve the cell mapping.""" + + def test_translate_to_zero(self) -> None: + t = _a()[2:10].translate_domain_to((0,)) + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((0,), (8,)) + assert _base_cells(t) == list(range(2, 10)) + + def test_translate_to_offset(self) -> None: + t = _a().translate_domain_to((5,)) + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((5,), (17,)) + assert _base_cells(t) == list(range(12)) + + def test_translate_by_composes_with_stride(self) -> None: + # a[::2].translate_by[5] -> domain [5, 11), base = 2*(coord-5) + t = _a()[::2].translate_domain_by((5,)) + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((5,), (11,)) + assert _base_cells(t) == [0, 2, 4, 6, 8, 10] + assert t[5].output[0].offset == 0 + assert t[10].output[0].offset == 10 + with pytest.raises(BoundsCheckError, match=r"valid indices \[5, 11\)"): + t[0] + + def test_translate_strided_to(self) -> None: + t = _a()[1:10:3].translate_domain_to((100,)) + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((100,), (103,)) + assert _base_cells(t) == [1, 4, 7] + + +class TestFancyDims: + """Oracle section 7: fancy dims get fresh [0, n); values are absolute coordinates.""" + + def test_index_array_values_are_coordinates(self) -> None: + v = _a()[2:10] + t = v.oindex[(np.array([3, 5], dtype=np.intp),)] + assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((0,), (2,)) + m = t.output[0] + assert isinstance(m, ArrayMap) + storage = m.offset + m.stride * m.index_array + np.testing.assert_array_equal(np.asarray(storage).ravel(), [3, 5]) + + def test_index_array_on_negative_origin(self) -> None: + t = _w().oindex[(np.array([-10, -1], dtype=np.intp),)] + m = t.output[0] + assert isinstance(m, ArrayMap) + storage = m.offset + m.stride * m.index_array + np.testing.assert_array_equal(np.asarray(storage).ravel(), [0, 9]) + + def test_index_array_out_of_domain_raises(self) -> None: + v = _a()[2:10] + for bad in ([0, 3], [-1, 3], [3, 10]): + with pytest.raises(BoundsCheckError): + v.oindex[(np.array(bad, dtype=np.intp),)] diff --git a/packages/zarr-indexing/tests/test_transform.py b/packages/zarr-indexing/tests/test_transform.py new file mode 100644 index 0000000000..baecd9ada2 --- /dev/null +++ b/packages/zarr-indexing/tests/test_transform.py @@ -0,0 +1,628 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform, selection_to_transform + + +class TestIndexTransformConstruction: + def test_from_shape(self) -> None: + t = IndexTransform.from_shape((10, 20)) + assert t.input_rank == 2 + assert t.output_rank == 2 + assert t.domain.shape == (10, 20) + assert t.domain.origin == (0, 0) + for i, m in enumerate(t.output): + assert isinstance(m, DimensionMap) + assert m.input_dimension == i + assert m.offset == 0 + assert m.stride == 1 + + def test_identity(self) -> None: + domain = IndexDomain(inclusive_min=(5,), exclusive_max=(15,)) + t = IndexTransform.identity(domain) + assert t.input_rank == 1 + assert t.output_rank == 1 + assert t.domain == domain + assert isinstance(t.output[0], DimensionMap) + assert t.output[0].input_dimension == 0 + + def test_from_shape_0d(self) -> None: + t = IndexTransform.from_shape(()) + assert t.input_rank == 0 + assert t.output_rank == 0 + assert t.domain.shape == () + + def test_custom_output_maps(self) -> None: + domain = IndexDomain.from_shape((10,)) + maps = (ConstantMap(offset=42), DimensionMap(input_dimension=0, offset=5, stride=2)) + t = IndexTransform(domain=domain, output=maps) + assert t.input_rank == 1 + assert t.output_rank == 2 + + def test_validation_input_dimension_out_of_range(self) -> None: + domain = IndexDomain.from_shape((10,)) + maps = (DimensionMap(input_dimension=5),) + with pytest.raises(ValueError, match="input_dimension"): + IndexTransform(domain=domain, output=maps) + + +class TestIndexTransformBasicIndexing: + def test_slice_identity(self) -> None: + """slice(None) on identity transform is a no-op.""" + t = IndexTransform.from_shape((10, 20)) + result = t[slice(None), slice(None)] + assert result.domain.shape == (10, 20) + assert result.input_rank == 2 + assert result.output_rank == 2 + + def test_slice_narrows(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = t[2:8, 5:15] + # Domains are preserved (TensorStore): the slice keeps its literal + # coordinates, so the map stays the identity (out = in). + assert result.domain.shape == (6, 10) + assert result.domain.origin == (2, 5) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == 0 + assert result.output[0].stride == 1 + assert result.output[0].input_dimension == 0 + assert isinstance(result.output[1], DimensionMap) + assert result.output[1].offset == 0 + assert result.output[1].input_dimension == 1 + + def test_strided_slice(self) -> None: + t = IndexTransform.from_shape((10,)) + result = t[::2] + assert result.domain.shape == (5,) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == 0 + assert result.output[0].stride == 2 + + def test_strided_slice_with_start(self) -> None: + t = IndexTransform.from_shape((10,)) + result = t[1:9:3] + # indices: 1, 4, 7 -> 3 elements + assert result.domain.shape == (3,) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == 1 + assert result.output[0].stride == 3 + + def test_int_drops_dimension(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = t[3] + assert result.input_rank == 1 + assert result.output_rank == 2 + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 3 + assert isinstance(result.output[1], DimensionMap) + assert result.output[1].input_dimension == 0 + + def test_int_middle_dimension(self) -> None: + t = IndexTransform.from_shape((10, 20, 30)) + result = t[:, 5, :] + assert result.input_rank == 2 + assert result.output_rank == 3 + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].input_dimension == 0 + assert isinstance(result.output[1], ConstantMap) + assert result.output[1].offset == 5 + assert isinstance(result.output[2], DimensionMap) + assert result.output[2].input_dimension == 1 + + def test_ellipsis(self) -> None: + t = IndexTransform.from_shape((10, 20, 30)) + result = t[2:8, ...] + assert result.input_rank == 3 + assert result.domain.shape == (6, 20, 30) + + def test_newaxis(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = t[np.newaxis, :, :] + assert result.input_rank == 3 + assert result.domain.shape == (1, 10, 20) + assert result.output_rank == 2 + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].input_dimension == 1 + assert isinstance(result.output[1], DimensionMap) + assert result.output[1].input_dimension == 2 + + def test_int_out_of_bounds(self) -> None: + t = IndexTransform.from_shape((10,)) + with pytest.raises(IndexError): + t[10] + + def test_negative_int_is_literal(self) -> None: + """Negative indices are literal coordinates (TensorStore convention), + not 'from the end' like NumPy.""" + t = IndexTransform.from_shape((10,)) + with pytest.raises(IndexError): + t[-1] # -1 is out of bounds for domain [0, 10) + + def test_negative_int_valid_with_negative_origin(self) -> None: + """Negative index is valid if the domain includes negative coordinates.""" + domain = IndexDomain(inclusive_min=(-5,), exclusive_max=(5,)) + t = IndexTransform.identity(domain) + result = t[-3] + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == -3 + + def test_composition_of_slices(self) -> None: + """Slicing a sliced transform re-selects in literal domain coordinates.""" + t = IndexTransform.from_shape((100,)) + result = t[10:50][15:30] + assert result.domain.shape == (15,) + assert result.domain.origin == (15,) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == 0 + assert result.output[0].stride == 1 + + def test_composition_of_strides(self) -> None: + t = IndexTransform.from_shape((100,)) + result = t[::2][::3] + # t[::2] -> shape (50,), offset=0, stride=2 + # [::3] -> shape ceil(50/3)=17, offset=0, stride=2*3=6 + assert result.domain.shape == (17,) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].stride == 6 + + def test_bare_int(self) -> None: + """Non-tuple selection.""" + t = IndexTransform.from_shape((10, 20)) + result = t[3] + assert result.input_rank == 1 + + def test_bare_slice(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = t[2:8] + assert result.domain.shape == (6, 20) + + +class TestBasicIndexingOnArrayMaps: + """When a transform already has ArrayMap outputs, basic indexing must + apply the corresponding operation to the index_array's axes.""" + + def test_int_on_array_map_drops_axis(self) -> None: + """Integer index on a dimension referenced by an ArrayMap should + index into the array on that axis.""" + arr = np.array([[10, 20], [30, 40], [50, 60]], dtype=np.intp) + # 2D input domain (3, 2), one ArrayMap output + t = IndexTransform( + domain=IndexDomain.from_shape((3, 2)), + output=(ArrayMap(index_array=arr),), + ) + # Index with int on dim 0 -> pick row 1 -> arr[1, :] = [30, 40] + result = t[1] + assert result.input_rank == 1 + assert result.domain.shape == (2,) + assert isinstance(result.output[0], ArrayMap) + np.testing.assert_array_equal(result.output[0].index_array, np.array([30, 40])) + + def test_slice_on_array_map(self) -> None: + """Slice on a dimension referenced by an ArrayMap should slice the array.""" + arr = np.array([10, 20, 30, 40, 50], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((5,)), + output=(ArrayMap(index_array=arr),), + ) + result = t[1:4] + assert result.domain.shape == (3,) + assert isinstance(result.output[0], ArrayMap) + np.testing.assert_array_equal(result.output[0].index_array, np.array([20, 30, 40])) + + def test_strided_slice_on_array_map(self) -> None: + """Strided slice on ArrayMap should stride the array.""" + arr = np.array([10, 20, 30, 40, 50], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((5,)), + output=(ArrayMap(index_array=arr),), + ) + result = t[::2] + assert result.domain.shape == (3,) + assert isinstance(result.output[0], ArrayMap) + np.testing.assert_array_equal(result.output[0].index_array, np.array([10, 30, 50])) + + def test_newaxis_on_array_map(self) -> None: + """Newaxis should insert an axis in the index_array.""" + arr = np.array([10, 20, 30], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=arr),), + ) + result = t[np.newaxis, :] + assert result.input_rank == 2 + assert result.domain.shape == (1, 3) + assert isinstance(result.output[0], ArrayMap) + assert result.output[0].index_array.shape == (1, 3) + np.testing.assert_array_equal(result.output[0].index_array, np.array([[10, 20, 30]])) + + def test_int_drops_one_of_two_array_dims(self) -> None: + """2D array map, int on dim 0, slice on dim 1.""" + arr = np.array([[10, 20, 30], [40, 50, 60]], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((2, 3)), + output=(ArrayMap(index_array=arr),), + ) + result = t[0, 1:3] + assert result.input_rank == 1 + assert result.domain.shape == (2,) + assert isinstance(result.output[0], ArrayMap) + # arr[0, 1:3] = [20, 30] + np.testing.assert_array_equal(result.output[0].index_array, np.array([20, 30])) + + +class TestIndexTransformOindex: + def test_oindex_int_array(self) -> None: + t = IndexTransform.from_shape((10, 20)) + idx = np.array([1, 3, 5], dtype=np.intp) + result = t.oindex[idx, :] + assert result.input_rank == 2 + assert result.domain.shape == (3, 20) + assert isinstance(result.output[0], ArrayMap) + # Full input rank: the array varies along its own axis (0), singleton on 1. + assert result.output[0].index_array.shape == (3, 1) + np.testing.assert_array_equal(result.output[0].index_array, idx.reshape(3, 1)) + assert result.output[0].offset == 0 + assert result.output[0].stride == 1 + assert isinstance(result.output[1], DimensionMap) + assert result.output[1].input_dimension == 1 + + def test_oindex_bool_array(self) -> None: + t = IndexTransform.from_shape((5,)) + mask = np.array([True, False, True, False, True]) + result = t.oindex[mask] + assert result.domain.shape == (3,) + assert isinstance(result.output[0], ArrayMap) + np.testing.assert_array_equal( + result.output[0].index_array, np.array([0, 2, 4], dtype=np.intp) + ) + + def test_oindex_mixed(self) -> None: + t = IndexTransform.from_shape((10, 20)) + idx = np.array([2, 4], dtype=np.intp) + result = t.oindex[idx, 5:15] + assert result.input_rank == 2 + assert result.domain.shape == (2, 10) + # fancy dim: fresh zero-origin; slice dim: preserved literal coords + assert result.domain.origin == (0, 5) + assert isinstance(result.output[0], ArrayMap) + assert isinstance(result.output[1], DimensionMap) + assert result.output[1].offset == 0 + + def test_oindex_multiple_arrays(self) -> None: + t = IndexTransform.from_shape((10, 20, 30)) + idx0 = np.array([1, 3], dtype=np.intp) + idx1 = np.array([5, 10, 15], dtype=np.intp) + result = t.oindex[idx0, :, idx1] + assert result.input_rank == 3 + assert result.domain.shape == (2, 20, 3) + assert isinstance(result.output[0], ArrayMap) + assert isinstance(result.output[1], DimensionMap) + assert isinstance(result.output[2], ArrayMap) + + def test_oindex_multiple_arrays_preserves_independent_axes(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = t.oindex[np.array([1, 3]), np.array([2, 4, 6])] + assert result.domain.shape == (2, 3) + assert isinstance(result.output[0], ArrayMap) + assert isinstance(result.output[1], ArrayMap) + assert result.output[0].index_array.shape == (2, 1) + assert result.output[1].index_array.shape == (1, 3) + + +class TestIndexTransformVindex: + def test_vindex_single_array(self) -> None: + t = IndexTransform.from_shape((10,)) + idx = np.array([1, 3, 5], dtype=np.intp) + result = t.vindex[idx] + assert result.input_rank == 1 + assert result.domain.shape == (3,) + assert isinstance(result.output[0], ArrayMap) + np.testing.assert_array_equal(result.output[0].index_array, idx) + + def test_vindex_broadcast(self) -> None: + t = IndexTransform.from_shape((10, 20)) + idx0 = np.array([[1, 2], [3, 4]], dtype=np.intp) + idx1 = np.array([[10, 11], [12, 13]], dtype=np.intp) + result = t.vindex[idx0, idx1] + assert result.input_rank == 2 + assert result.domain.shape == (2, 2) + assert isinstance(result.output[0], ArrayMap) + assert isinstance(result.output[1], ArrayMap) + np.testing.assert_array_equal(result.output[0].index_array, idx0) + np.testing.assert_array_equal(result.output[1].index_array, idx1) + + def test_vindex_with_slice(self) -> None: + t = IndexTransform.from_shape((10, 20, 30)) + idx = np.array([1, 3, 5], dtype=np.intp) + result = t.vindex[idx, :, :] + assert result.input_rank == 3 + assert result.domain.shape == (3, 20, 30) + assert isinstance(result.output[0], ArrayMap) + + def test_vindex_bool_mask(self) -> None: + t = IndexTransform.from_shape((5,)) + mask = np.array([True, False, True, False, True]) + result = t.vindex[mask] + assert result.domain.shape == (3,) + assert isinstance(result.output[0], ArrayMap) + + def test_vindex_broadcast_different_shapes(self) -> None: + t = IndexTransform.from_shape((10, 20)) + idx0 = np.array([1, 2, 3], dtype=np.intp) + idx1 = np.array([[10], [11]], dtype=np.intp) + result = t.vindex[idx0, idx1] + assert result.input_rank == 2 + assert result.domain.shape == (2, 3) + + def test_vindex_multiple_arrays_preserves_shared_axes(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = t.vindex[np.array([1, 3]), np.array([2, 4])] + assert result.domain.shape == (2,) + assert isinstance(result.output[0], ArrayMap) + assert isinstance(result.output[1], ArrayMap) + assert result.output[0].index_array.shape == (2,) + assert result.output[1].index_array.shape == (2,) + + +class TestSelectionToTransform: + def test_basic_slice(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = selection_to_transform((slice(2, 8), slice(5, 15)), t, "basic") + assert result.domain.shape == (6, 10) + assert result.domain.origin == (2, 5) # preserved literal coordinates + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == 0 + + def test_basic_int(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = selection_to_transform((3, slice(None)), t, "basic") + assert result.input_rank == 1 + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 3 + + def test_basic_ellipsis(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = selection_to_transform(Ellipsis, t, "basic") + assert result.domain.shape == (10, 20) + + def test_orthogonal(self) -> None: + t = IndexTransform.from_shape((10, 20)) + idx = np.array([1, 3, 5], dtype=np.intp) + result = selection_to_transform((idx, slice(None)), t, "orthogonal") + assert result.domain.shape == (3, 20) + assert isinstance(result.output[0], ArrayMap) + + def test_vectorized(self) -> None: + t = IndexTransform.from_shape((10, 20)) + idx0 = np.array([1, 3], dtype=np.intp) + idx1 = np.array([5, 7], dtype=np.intp) + result = selection_to_transform((idx0, idx1), t, "vectorized") + assert result.domain.shape == (2,) + assert isinstance(result.output[0], ArrayMap) + assert isinstance(result.output[1], ArrayMap) + + def test_composition_with_non_identity(self) -> None: + """Indexing a sliced transform uses literal domain coordinates. + + The slice [10:50] preserves its domain, so a follow-up [15:30] + re-selects coordinates 15..29 of the base (TensorStore semantics), and + the composed map stays the identity (out = in). + """ + t = IndexTransform.from_shape((100,))[10:50] + result = selection_to_transform(slice(15, 30), t, "basic") + assert (result.domain.inclusive_min, result.domain.exclusive_max) == ((15,), (30,)) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == 0 + assert result.output[0].stride == 1 + + +class TestIndexTransformIntersect: + def test_constant_inside(self) -> None: + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(ConstantMap(offset=5),), + ) + result = t.intersect(IndexDomain(inclusive_min=(0,), exclusive_max=(10,))) + assert result is not None + restricted, surviving = result + assert isinstance(restricted.output[0], ConstantMap) + assert restricted.output[0].offset == 5 + assert surviving is None + + def test_constant_outside(self) -> None: + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(ConstantMap(offset=5),), + ) + result = t.intersect(IndexDomain(inclusive_min=(10,), exclusive_max=(20,))) + assert result is None + + def test_dimension_partial(self) -> None: + """DimensionMap over [0,10) intersected with [5,15) narrows input to [5,10).""" + t = IndexTransform.from_shape((10,)) + result = t.intersect(IndexDomain(inclusive_min=(5,), exclusive_max=(15,))) + assert result is not None + restricted, surviving = result + assert restricted.domain.inclusive_min == (5,) + assert restricted.domain.exclusive_max == (10,) + assert surviving is None + + def test_dimension_no_overlap(self) -> None: + t = IndexTransform.from_shape((10,)) + result = t.intersect(IndexDomain(inclusive_min=(20,), exclusive_max=(30,))) + assert result is None + + def test_dimension_strided(self) -> None: + """stride=2, offset=1 over [0,5): storage 1,3,5,7,9. Chunk [4,8).""" + t = IndexTransform( + domain=IndexDomain.from_shape((5,)), + output=(DimensionMap(input_dimension=0, offset=1, stride=2),), + ) + result = t.intersect(IndexDomain(inclusive_min=(4,), exclusive_max=(8,))) + assert result is not None + restricted, _surviving = result + # input 2->5, input 3->7. Both in [4,8). + assert restricted.domain.inclusive_min == (2,) + assert restricted.domain.exclusive_max == (4,) + + def test_array_partial(self) -> None: + arr = np.array([3, 8, 15, 22], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((4,)), + output=(ArrayMap(index_array=arr),), + ) + result = t.intersect(IndexDomain(inclusive_min=(5,), exclusive_max=(20,))) + assert result is not None + restricted, surviving = result + assert isinstance(restricted.output[0], ArrayMap) + np.testing.assert_array_equal(restricted.output[0].index_array, np.array([8, 15])) + assert surviving is not None + np.testing.assert_array_equal(surviving, np.array([1, 2])) + + def test_array_none_inside(self) -> None: + arr = np.array([1, 2, 3], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=arr),), + ) + assert t.intersect(IndexDomain(inclusive_min=(10,), exclusive_max=(20,))) is None + + def test_2d_mixed(self) -> None: + """2D: ConstantMap on dim 0, DimensionMap on dim 1.""" + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=( + ConstantMap(offset=5), + DimensionMap(input_dimension=0, offset=0, stride=1), + ), + ) + chunk = IndexDomain(inclusive_min=(0, 5), exclusive_max=(10, 15)) + result = t.intersect(chunk) + assert result is not None + restricted, _ = result + assert isinstance(restricted.output[0], ConstantMap) + assert restricted.output[0].offset == 5 + assert isinstance(restricted.output[1], DimensionMap) + assert restricted.domain.inclusive_min == (5,) + assert restricted.domain.exclusive_max == (10,) + + +class TestIndexTransformTranslate: + def test_translate_constant(self) -> None: + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(ConstantMap(offset=5),), + ) + result = t.translate((-5,)) + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 0 + + def test_translate_dimension(self) -> None: + t = IndexTransform.from_shape((10,)) + result = t.translate((-3,)) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == -3 + assert result.output[0].stride == 1 + + def test_translate_array(self) -> None: + arr = np.array([5, 10], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((2,)), + output=(ArrayMap(index_array=arr, offset=3),), + ) + result = t.translate((-3,)) + assert isinstance(result.output[0], ArrayMap) + assert result.output[0].offset == 0 + np.testing.assert_array_equal(result.output[0].index_array, arr) + + def test_translate_2d(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = t.translate((-5, -10)) + assert isinstance(result.output[0], DimensionMap) + assert result.output[0].offset == -5 + assert isinstance(result.output[1], DimensionMap) + assert result.output[1].offset == -10 + + +class TestArrayMapDependencyAxes: + """`_array_map_dependency_axes` derives the input axes an array varies on + from its (full-rank) shape: non-singleton axes vary, singleton axes do not.""" + + def test_orthogonal_single_axis(self) -> None: + from zarr_indexing.transform import _array_map_dependency_axes + + t = IndexTransform.from_shape((10, 20)).oindex[np.array([1, 3]), np.array([2, 4, 6])] + m0, m1 = t.output[0], t.output[1] + assert isinstance(m0, ArrayMap) + assert isinstance(m1, ArrayMap) + assert _array_map_dependency_axes(m0.index_array) == (0,) + assert _array_map_dependency_axes(m1.index_array) == (1,) + + def test_vectorized_shares_axes(self) -> None: + from zarr_indexing.transform import _array_map_dependency_axes + + t = IndexTransform.from_shape((10, 20)).vindex[np.array([1, 3]), np.array([2, 4])] + m0, m1 = t.output[0], t.output[1] + assert isinstance(m0, ArrayMap) + assert isinstance(m1, ArrayMap) + assert _array_map_dependency_axes(m0.index_array) == (0,) + assert _array_map_dependency_axes(m1.index_array) == (0,) + + def test_scalar_array_has_no_dependency(self) -> None: + from zarr_indexing.transform import _array_map_dependency_axes + + assert _array_map_dependency_axes(np.ones((1, 1), dtype=np.intp)) == () + + +class TestIntersectArrayMapClassification: + """`_intersect` must distinguish orthogonal (outer-product) ArrayMaps from + correlated (vectorized) ones by their dependency axes, keep surviving arrays + at full input rank, and preserve residual (slice) dimensions.""" + + def test_orthogonal_outer_product_keeps_full_rank(self) -> None: + """Two arrays on distinct axes narrow independently and stay full rank; + out_indices is a per-output-dim dict of surviving positions.""" + t = IndexTransform.from_shape((10, 10)).oindex[np.array([1, 3, 8]), np.array([2, 6, 9])] + # Chunk covering storage [0,5) x [0,5): rows 1,3 survive (out pos 0,1), + # cols 2 survives (out pos 0). + chunk = IndexDomain(inclusive_min=(0, 0), exclusive_max=(5, 5)) + result = t.intersect(chunk) + assert result is not None + restricted, out_indices = result + assert isinstance(restricted.output[0], ArrayMap) + assert isinstance(restricted.output[1], ArrayMap) + # Full input rank preserved (not raveled to 1-D). + assert restricted.output[0].index_array.ndim == 2 + assert restricted.output[1].index_array.ndim == 2 + assert restricted.domain.ndim == 2 + assert isinstance(out_indices, dict) + np.testing.assert_array_equal(out_indices[0], np.array([0, 1])) + np.testing.assert_array_equal(out_indices[1], np.array([0])) + + def test_correlated_with_residual_slice_preserves_slice_dim(self) -> None: + """A vindex transform with two correlated arrays plus a residual slice + dim intersects without a rank error and keeps the DimensionMap.""" + t = IndexTransform.from_shape((4, 3, 5)).vindex[np.array([1, 3]), np.array([2, 0])] + # Chunk covering storage [0,2) x [2,3) x [0,5): only point (1,2,*) is in + # bounds on both array dims -> one surviving broadcast point. + chunk = IndexDomain(inclusive_min=(0, 2, 0), exclusive_max=(2, 3, 5)) + result = t.intersect(chunk) + assert result is not None + restricted, out_indices = result + # A DimensionMap for the residual slice dim survives (no post-init error). + assert any(isinstance(m, DimensionMap) for m in restricted.output) + assert out_indices is not None + + def test_length1_orthogonal_not_treated_as_correlated(self) -> None: + """A length-1 orthogonal array (all-singleton shape) is still an outer + product with the length-3 axis: out_indices is a dict, not a flat array.""" + t = IndexTransform.from_shape((6, 6)).oindex[np.array([2]), np.array([1, 3, 5])] + chunk = IndexDomain(inclusive_min=(0, 0), exclusive_max=(6, 6)) + result = t.intersect(chunk) + assert result is not None + _restricted, out_indices = result + assert isinstance(out_indices, dict) diff --git a/pyproject.toml b/pyproject.toml index 727071b5a3..daa6b99d48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,15 @@ dev = [ "mypy==2.1.0", ] +# The repo is a uv workspace so the in-tree `packages/zarr-indexing` package +# resolves from a checkout during development and CI. zarr itself does not +# (yet) depend on zarr-indexing — the runtime wiring lands separately. +[tool.uv.workspace] +members = ["packages/zarr-indexing"] + +[tool.uv.sources] +zarr-indexing = { workspace = true } + [tool.coverage.report] exclude_also = [ 'if TYPE_CHECKING:', diff --git a/uv.lock b/uv.lock index 6035acc616..843d3dd1c8 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,12 @@ resolution-markers = [ "python_full_version < '3.15'", ] +[manifest] +members = [ + "zarr", + "zarr-indexing", +] + [[package]] name = "aiobotocore" version = "3.7.0" @@ -3604,3 +3610,21 @@ test = [ { name = "tomlkit", specifier = "==0.15.0" }, { name = "uv", specifier = "==0.11.26" }, ] + +[[package]] +name = "zarr-indexing" +source = { editable = "packages/zarr-indexing" } +dependencies = [ + { name = "numpy" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=2" }] + +[package.metadata.requires-dev] +test = [{ name = "pytest" }]