Ragged store layout (zagg-ragged/1)¶
A kind: ragged field (a per-cell t-digest, a per-cell photon list — anything
whose per-cell length varies) is stored as one self-describing
variable_length_bytes array on the cell grid. This is the
issue #209 layout; it replaced
the per-inner-chunk CSR subgroups (values/offsets/cell_ids, ~7 objects per
populated inner chunk) with a single vlen array per field.
Normative home. The byte-level contract — the sibling-array layout, the versioned
raggedattrs block, the codec chain, the golden wire framing, the ShardingCodec geometry, and the succession plan — isspecification.md§1, with the digest payload semantics in §2. This page is the narrative companion: the why behind the layout, and how zagg's own readers consume it. Where the two disagree, the spec wins.
The design goals the layout meets: one object per shard on the write side (deleting the ~K×7 tiny-PUT storm and the issue #142 write-fanout thread pool that existed only to parallelize it), 2-GET random access to any single cell on the read side, and a wire format frozen tightly enough that the issue #210 typed-dtype migration is metadata-only.
Why the attrs are the contract¶
The element interpretation (dtype, inner shape, the companion siblings' names)
is self-describing in the array's attrs — the
spec §1.2 block — so a reader
decodes exactly what the writer declared rather than hardcoding a dtype or
reconstructing a naming convention (review finding, PR #211). The block is
versioned (spec: "zagg-ragged/1") and strict-checked
(readers/tdigest_tensor._open_ragged): an unknown/future spec raises,
never half-parses — the coverage-envelope discipline applied to the ragged
layout. A vlen array without a well-formed element declaration is not a
zagg ragged array — pre-issue-209 CSR stores are a hard break, and the readers
raise a pointed error rather than decode under a guessed layout.
Why the companion words are declared, not implied¶
A companion sibling holds packed words, not self-describing scalars — a
morton cell in {field}_locations, a toc time in {field}_times — so
nothing in a uint64 array says which grammar produced it. The spec's
word-typed declaration
(§8, instantiated for space in
§9) closes that: a {spec, shape,
grammar} block, stamped by the writer on the array that holds the words,
with the payload array carrying only the binding to it. One rule follows
from that split and is worth stating plainly here: read the declaration off
the sibling you bound, never off the payload.
Two properties the family is designed around:
- Absence is never a refusal. Every located store written before §9
carries no
locatedkey, and reads exactly as §2.2 always said it did. The declaration adds self-description and the shape vocabulary; it never reinterprets a byte. - Composition gates on
{shape, grammar}— the same discipline the §2.0weightsdeclaration established, and for the same reason: merging two arrays whose words mean different things yields an array that means neither.
The binding for the temporal sibling is a sibling attrs key (times),
not a key inside the versioned ragged block, matching weights and for the
same reason — the block is retired wholesale under
zagg-ragged/2, and a key outside it
survives that metadata-only migration untouched. locations predates that
ruling and stays where it is; the asymmetry is history, not design.
Why the wire framing is golden-pinned¶
Round-trip tests pass under any self-consistent encoding, so only a fixed
byte vector freezes a convention. The
spec §1.4 framing is pinned by a golden
test
(tests/test_processing.py::TestRaggedVlenLayout::test_golden_inner_chunk_framing)
and by the committed conformance fixtures
(spec §7). That pin is what
guarantees the later metadata-only migration to a typed vlen-ndarray dtype
(byte-compatible with numcodecs VLenArray) without rewriting data — see
spec §6.
Why the unsharded flat K>1 path keeps per-inner-chunk objects¶
Both stored geometries (spec §1.5)
hold the same logical data; which one a product gets depends on the write
path (grids.base.ragged_array_spec, shard_shape argument). The streaming
write path (write_ragged_to_zarr, the runner / Lambda streaming callback)
writes each chunk independently as it is produced, then frees it — the
issue #91 stream-and-free
bound. "Streaming" here is the output-side write discipline, which the
production runner uses on every path — including the default pooled-input
aggregation (the shard's reads are pooled once, then each chunk's output
carrier is built, written through the callback, and freed). It is distinct
from the buffered/streaming input path (issue #148 phase 4); input pooling
and output stream-and-free are orthogonal, and a pooled-input production run
still writes chunk-at-a-time through this path. Folding all K chunks into one sharded object would force a
read-modify-write of that shared object on every chunk, defeating
stream-and-free and re-introducing the memory the sharded slab pass is careful
to bound. So the regular-chunked (one object per inner chunk) layout is
retained there (the PR #211 review's Q1 resolution). It is not a reader
fork: the reader derives the stored span from arr.shards or arr.chunks and
reads either identically (pinned by
test_sharded_and_regular_layouts_read_identically — same logical data
through both writers yields identical tensors and chunk ids).
The GET counts in the spec's geometry table are for the data objects only and
exclude the one-time array-open metadata read (amortized across all cells of a
store). The sharded 2-GET count is pinned by
test_two_ranged_gets_on_sharded_store; the unsharded 1-GET count is analytic
(a regular array indexes the single chunk holding the cell).
The hive-leaf reader contract¶
A hive leaf zarr is exactly this layout scoped to one shard.
Under the leaf's {group} path a reader finds the ragged vlen array with its
versioned ragged attrs, the sibling morton coordinate array (chunk
identity), and the field's companion siblings — {field}_locations for a
located field, {field}_times for one carrying the per-centroid temporal
companion (spec §8.3) — all
sharded as one whole-leaf ShardingCodec object (one stored span). So a hive
product is read one leaf at a time: open the leaf store
(hive.shard_leaf_path) and pass the same field path to the readers. The
readers are store-scoped and never traverse the hive digit tree — leaf
discovery is the coverage MOC's / walker's job
(issue #200). The flat-sharded
and hive-leaf writers are pinned to store byte-identical per-cell payloads
(test_hive_leaf_parity_with_flat_sharded) so the two backends cannot drift.
Read paths¶
zagg.readers.tdigest_tensor consumes the layout two ways:
- Whole-store sweep (
read_tensors,read_raw_values,read_locations) — one LIST of the array's storedc/<ordinal>objects (_stored_chunk_spans), then a per-read-chunk decode. The sweep visits only written data; each stored object is read in one slice (a sharded object's index suffix is fetched once, not re-fetched per inner chunk). Each read chunk is a square(side, side)block of cells (side = isqrt(cells_per_chunk), 64 for the productionchunk_innerconfigs), and its coverage-cell morton id is derived from the siblingmortoncoordinate coarsened to the chunk order. Cell placement within the block is the spec-pinned bit deinterleave — see the tensor section below. - Single-cell random access (
read_cell) — indexes the vlen array directly. On a sharded store that is exactly 2 ranged GETs (the shard-index suffix, then the one inner chunk holding the cell), never the whole shard object. An out-of-range index raisesIndexErrornaming the valid range (no negative-index wrap); an absent cell returns the zero-length(0, *inner_shape)array. Works on the{field}_locationsand{field}_timessiblings too — both are themselveszagg-ragged/1uint64 arrays. - Sub-leaf subtree read (
subtree=on the sweep readers — issue #351) — the nested cells axis makes the subtree below any ancestor one contiguous cell span (normative in the spec's §1.5 subtree-spans clause), soread_tensors,read_raw_values, andread_locationstakesubtree=(a packed area word or its decimal string) and slice only that span: the shard-index suffix plus the covering inner chunks —read_cell's 2-GET pattern generalized to a span, never the whole-array sweep. The per-cell readers accept any order down to a single cell;read_tensorsemits whole read chunks only (max(subtree_order, axis_root_order) ≤ block_order ≤ chunk_order— the floor is the order of the span actually visited, i.e. the subtree clipped to the axis, so a word above the axis root takes the root's order) and refuses finer words, pointing at the per-cell readers. A well-formed word disjoint from the axis warns at most once per call and yields nothing — so an empty yield is ambiguous without the warning, and whether the warning is delivered is the caller'swarningsfilter's call (the default action dedups a repeat of the same warning in one process) — while malformed or too-deep words raise.
Spatially faithful tensors (deinterleave, blocks, mask)¶
The nested cells axis traces a Z-order curve within each chunk subtree, so
a row-major reshape of the 1-D axis scrambles the 2-D block spatially beyond
2-cell runs. The readers instead place each cell at the bit deinterleave
of its chunk-local nested rank
(issue #336):
mortie.rank_to_xy / xy_to_rank (added in mortie 0.9.3), whose contract is
normative in mortie's docs/specification.md §8 and frozen for mortie 1.x
— x gathers the rank's even bits, y its odd bits, origin (0, 0) at the
subtree's south corner, equal to healpy's face-local pix2xyf (nest)
convention. zagg pins row = y, col = x (readers/_layout.py), matching
gridlook's bit_combine(j, i) texture convention
(gridlook/src/ui/grids/Healpix.vue), so an emitted tensor uploads to a
gridlook texture with no further shuffle. The golden placement vectors in
tests/test_reader_layout.py are imported from mortie's spec-pinned test
suite, never re-derived.
read_tensors yields the full reader contract per block:
for tensor, mask, (offset, gain), morton in read_tensors(store, field):
... # tensor: (side, side, n_bins); mask: (side, side) uint8
- Blocks — by default one block per read chunk;
block_order=assembles the4**(chunk_order - block_order)chunks of one block-order subtree into a single(2**d, 2**d, n_bins)tensor (d = cell_order - block_order; an order-12 block on production geometry is 128×128 from 4 inner chunks). The z-window andfitpolicy are reconciled block-wide: one shared(offset, gain) = (z_lo, resolution)per block, so binicovers[offset + i*gain, offset + (i+1)*gain)for every cell in the block. Coarser than the stored shard assembles too (the block-local index is still the nested rank), but the memory bound is then per block: the block's decoded digests are all held for the block-wide window, and the emitted tensor grows 4× per coarser order (an order-6 block on the production order-19 geometry would be 34 TB).max_block_bytes=(2 GiB default) refuses that allocation with a pointed error instead of a bareMemoryError. - Mask — the block's occupancy channel on the same deinterleaved layout:
0unobserved,1observed with no stored digest,2observed with data. States ½ come from the hive leaf'scoverage.mococcupancy sidecar (issue #200; decoded by the frozenhive.decode_coverage_bitmapconvention, one small sidecar object, no digest bytes). A store without exact occupancy (every flat store, or a box-only/full-less missing sidecar) degrades to the 2-state{0, 2}populated/not mask, where0means only "no stored digest" and asserts nothing about whether the cell was observed. The mask does not carry which regime it is in — a degraded mask and a 3-state mask over a block with no observed-but-empty cell are both{0, 2}— so checkhas_exact_occupancy(store)before keying onmask == 1; without it, an empty noise stratum on a degraded store reads as a genuine absence. Today occupancy equals digest coverage, so state1does not occur; once the issue #334 signal strata land, noise-occupied cells appear as1automatically — the 3-state upgrade is data-driven, not a reader flag.
read_raw_values / read_locations report the same deinterleaved
(row, col) per cell. readers._layout.rowcol_to_rank inverts that to the
chunk-local nested rank (0..4**depth - 1) — not a read_cell key,
which is a global cells-axis index; the chunk's start offset is the
missing term, and a bare rank is always in range so read_cell would read
the wrong cell without raising. cell_index composes the two:
for morton, (row, col), values in read_raw_values(store, field):
cell = cell_index(store, field, morton, row, col) # chunk_start + rank
assert (read_cell(store, field, cell)[:, 0] == values).all()
It resolves the offset from the sibling morton coordinate, searching only
the array's stored spans (the populated chunks the sweep readers yield from)
and no digest bytes. morton_index must be a read-chunk id — a coarser
block_order block id names no single chunk and raises.
Issue #210 typed-dtype migration¶
The ragged attrs block is the element contract for /1 stores — pinned as
the 1.0 wire contract, valid indefinitely. The
issue #210 migration adds a
/2 revision whose chunk bytes are identical and whose element declaration
moves into a typed vlen-ndarray data type; the succession mechanics are
normative in spec §1.6 and
spec §6.