Skip to content

The ndsel wire format

ndsel is a draft JSON representation of NumPy-style n-dimensional selections, adapted from TensorStore's IndexTransform model. This page documents the wire format, not the coordinate model: Coordinates are addresses introduces literal coordinates, and Lazy views compose shows how views combine before a transform is serialized. zarr-indexing implements ndsel in two layers:

Layer Module Depends on Job
Message zarr_indexing.messages stdlib only JSON in, canonical JSON out. Validates and desugars. Never rounds, clamps, or drops information.
Engine zarr_indexing.json NumPy Lowers a canonical body into an in-memory IndexTransform, and back.

Constraints that only make sense for a real array — finite bounds, index arrays as ndarrays — live in the engine layer and nowhere else. As a result, messages normalizes a message with "-inf" bounds that IndexTransform.from_json refuses to lower.

Two entry points

parse_ndsel structurally validates a message of any kind and returns it unchanged. Use it to confirm that a message is well formed while keeping it in its compact shorthand form.

normalize_ndsel desugars a message into the single deterministic canonical transform body of the spec (section 4.3): a bare IndexTransform body without the kind discriminator.

from zarr_indexing import normalize_ndsel

normalize_ndsel({"kind": "box", "inclusive_min": [10, 5], "shape": [40, 1]})
# {'input_rank': 2,
#  'input_inclusive_min': [10, 5],
#  'input_exclusive_max': [50, 6],
#  'input_labels': ['', ''],
#  'output': [{'offset': 0, 'stride': 1, 'input_dimension': 0},
#             {'offset': 0, 'stride': 1, 'input_dimension': 1}]}

Normalization is idempotent: re-tag the output with kind: "transform" and normalizing it again returns the same body. Because the canonical body is field-for-field a TensorStore IndexTransform minus kind, a normalized message loads directly into tensorstore.IndexTransform(json=...).

Both entry points raise NdselError, which carries the spec reason code (unknown_kind, rank_mismatch, step_zero, output_map_conflict, …) alongside a human-readable detail, so callers can branch on the code rather than on message text.

The five message kinds

Four are shorthands; the fifth is the canonical form itself.

kind Fields Selects
point coords A single element. Normalizes to rank 0 with one constant output map per dimension.
box inclusive_min, one of exclusive_max / inclusive_max / shape, labels A rectangular region. Exactly one upper-bound spelling may appear.
slice start, stop, step, labels A strided region, one Python-style slice per dimension.
points coords (a list of coordinate rows) An explicit list of points — the vindex case. Normalizes to one index_array output map per dimension over a shared rank-1 input domain.
transform input_rank, input_inclusive_min, one of the three input_* upper bounds, input_labels, output The full canonical form.

Value rules the message layer enforces throughout: 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; and an implicit bound is the one-element [n]-bracket form, whose implicit/explicit flag survives normalization intact.

Lowering to a transform

The engine layer converts between canonical bodies and IndexTransforms:

from zarr_indexing import IndexTransform

t = IndexTransform.from_json(canonical)
t.to_json() == canonical

IndexDomain carries the same pair for a bare domain body, and each output map kind has a to_json; output_index_map_from_json dispatches the wire's tagged union back to the right kind.

Two engine constraints apply here and only here. A canonical body carrying a "-inf" or "+inf" bound cannot be lowered — an IndexDomain addresses a finite array — so IndexTransform.from_json raises. And implicit bounds lower by value: the [n]-bracket flag is a message-layer concern, and the engine keeps only the integer.

The index_array round trip

ndsel and TensorStore both reject an output map that carries both input_dimension and index_array. The in-memory ArrayMap, though, records an input_dimension to pin the axis an orthogonal (oindex) array varies over. The serializer bridges that gap in both directions:

  • On serialize, a non-degenerate index_array map is emitted without input_dimension.
  • On load, the in-memory input_dimension is reconstructed from the full-rank array's dependency axes — its non-singleton axes. An array that solely owns a single non-singleton axis is orthogonal; arrays that share non-singleton axes, or vary over several, are correlated (vindex), and get input_dimension = None. A single 1-D array over a rank-1 domain is inherently ambiguous between the two flavors and reconstructs as orthogonal, which is behaviorally identical in that case.

There is one deliberate exception, and it is the only place a round trip changes representation rather than preserving it. An all-singleton index_array — size 1 — selects the same coordinate regardless of the input, so it is collapsed to a constant map on serialize:

from zarr_indexing import IndexTransform

IndexTransform.from_shape((100, 100)).oindex[[5], 0:2].to_json()
# {'input_rank': 2,
#  'input_inclusive_min': [0, 0],
#  'input_exclusive_max': [1, 2],
#  'input_labels': ['', ''],
#  'output': [{'offset': 5},
#             {'offset': 0, 'stride': 1, 'input_dimension': 1}]}

The size-1 input dimension stays in the domain, unconsumed by any output map. The transform is still valid and the output shape is unchanged. A length-1 oindex selection therefore round-trips behaviorally (an ArrayMap comes back as a ConstantMap) rather than by object identity.

Conformance

The package is checked against the language-agnostic ndsel conformance corpus, vendored unmodified under tests/conformance/ — one JSON file per message kind plus errors.json, with the source commit recorded in PROVENANCE.md. Each fixture is either a success case (input + expected normalized body) or an error case (input + expected reason code), and an implementation is conformant iff normalize reproduces every one. tests/test_conformance.py runs the whole corpus as one parametrized test per fixture, so a corpus update reports failures fixture by fixture rather than as a single opaque assertion.

Do not edit the vendored files; to pick up spec changes, re-vendor from a newer ndsel commit and update the recorded SHA.

A second, optional test (tests/test_ndsel_tensorstore.py, skipped unless tensorstore is installed) checks against TensorStore itself by loading canonical bodies into tensorstore.IndexTransform and re-loading TensorStore's own to_json() output back through the engine layer.