Concepts
moczarr is the read side of a convention specified elsewhere: the
morton-hive layout and morton decimal ids are owned by the
mortie specification,
and the coverage tiers, commit-stamp semantics, and reader architecture by
zagg's sparse_coverage.md
(§4 coverage, §5 reader, §6 xarray extension). This page is the working
summary a reader of this package needs; those documents are normative.
The hive tree
A morton-hive store is a digit tree of self-describing zarr v3 leaves:
{store_root}/
morton_hive.json <- static manifest; root-only exception
coverage.moc <- root ranges MOC; root-only exception
{sign+base}/{d1}/.../{d_n}/ <- one decimal digit per level
{full_id}.zarr/ <- vanilla zarr v3 leaf
{full_id}_{window}.zarr/ <- time-windowed leaf (morton-hive/2)
Ids are morton decimal strings: a sign, a base digit (1..6), then one
digit 1..4 per order. A string prefix is a spatial ancestor — which is
what makes shard paths, AOI covers, and cross-resolution joins arithmetic.
Below the root, a node holds only digit children and *.zarr objects (the
node invariant); the manifest and the root coverage.moc are the two
root-only exceptions.
A leaf is complete iff its root zarr attrs carry the commit stamp
(morton_hive_commit); an unstamped .zarr/ prefix is debris a torn
worker left behind, and the reader skips it. Presence requires the stamp;
absence (a clean GET/LIST miss) is trustworthy on its own.
Versioned leaves
A leaf's stable prefix may be a pointer rather than the data itself
(zagg spec §1.5; what zagg's hive writers produce by default from
englacial/zagg#585 on). The root stamp then carries one more key,
current, naming a version subgroup that holds the arrays, the
coverage.moc sidecar and a stamp of its own:
{full_id}.zarr/zarr.json <- stamp + "current": "run-{run_id}-{attempt}"
{full_id}.zarr/run-{run_id}-{attempt}/zarr.json <- the version's own stamp (the same, minus current)
{full_id}.zarr/run-{run_id}-{attempt}/{cell_order}/... <- the arrays (coverage.moc beside them)
The reader carries one rule — open the root; if its stamp names current
the arrays are under {leaf}/{current}/, else under {leaf}/ — and
moczarr.store.leaf_data_prefix is the one place that applies it. On
open_hive (and open_level, which rides it) the stamp is the object the
open reads anyway, so following the pointer costs no request. open_leaf
and read_coverage_bitmap read the root stamp themselves — one GET —
unless an already-read stamp is threaded with stamp= (e.g. from one
batched read_commits). A stamp without current is a legacy leaf (every
store written before the revision), so nothing needs migrating and one
store may hold both kinds. A replacement writes a new version and swaps
the pointer; a superseded version stays at its keys until zagg's collector
reclaims it, and is never read through the stable address.
What does not move: the stamp read itself (the root mirrors the
version's stamp, so the coverage box, window and content_hashes still
come off the root), and the leaf's siblings at its node — the stats
sidecar, granules.json, the *.pyramid.zarr column — which stay beside
the stable root, unversioned. Columns and overviews are not versioned.
A current that is not a version name (one path component beginning
run-) raises. A pointer naming a version that is missing or unstamped is
a corrupted leaf — the writer lands the pointer only after the version is
stamped, so it means the version was removed out of band — and is read as
debris: open_hive skips a missing version with a warning, the verifier
reports no arrays. One gap is deliberate: the open never reads the
version's own stamp (that would be one more GET per versioned leaf), so a
version whose stamp alone is gone, arrays intact, is still served by
open_hive — only the verifier, which reads that object anyway, calls it
debris.
Coverage tiers
Coverage — where data exists — is declared in tiers so a reader can reject non-intersecting shards as cheaply as possible and only pay for precision where the AOI actually lands:
- Root ranges MOC (
coverage.mocat the root): inclusive[first, last]runs of same-order shard ids. One GET names every shard worth considering. It is a cache tier: absent or unusable, the reader degrades to the discovery walk — never to a wrong answer. - Leaf tier-0 box (in the commit stamp): up to four morton ids bounding the leaf's coverage. Intersection with the AOI is a cheap conservative reject (false positives only).
- Leaf bitmap (
coverage.mocsidecar inside the leaf, whenencoding: "bitmap"): exact cell-order occupancy as a zstd-compressed bit field.encoding: "full"means the whole subtree is covered and no sidecar exists. A present-but-corrupt bitmap raises rather than degrading — silently zero-padding would fabricate false negatives, indistinguishable from healthy sparse coverage.
Domain vs. occupancy
Two different questions, deliberately kept apart:
- The domain is the set of rows a dataset has: the shard subtrees the coverage arithmetic selected, intersected with the AOI. zagg leaves are dense within a shard, so the domain is pure arithmetic — every cell of a selected subtree is a row, occupied or not.
- Occupancy is data-plane: which of those rows hold observations
(
count > 0, non-fill values, the bitmap sidecar's exact answer).
The lazy index below indexes the domain. Occupancy-aware selection is an
ordinary data-plane filter (ds.where(ds["count"] > 0, drop=True)), not
index semantics — keeping the index in lockstep with the rows the leaves
actually store.
The lazy index
open_hive's default index posture (index_kind="moc") replaces
read-and-materialize with the §6 posture: the row domain is held as a
MortonRanges interval set built
from the same coverage arithmetic that selected the leaves, and the
morton coordinate is fabricated from it on demand by a MortonMocIndex
(a plain xarray.Index — core, no xdggs needed). The on-disk
morton/cell_ids arrays are never read: tools/bench_open.py pins
zero coordinate-chunk GETs for a moc open, and the result is
value-identical to the materialized index_kind="pandas" path (one kwarg
away when a workflow needs it).
The substrate works in rank space, not word space: packed words at a
fixed cell order are not unit-stride across a shard subtree, but
word >> shift (the base+digit field) is a global unit-stride rank
coordinate. Shard subtrees, AOI intersection, sel/isel, and alignment
are all searchsorted/cumsum arithmetic on inclusive [lo, hi] interval
pairs; the (shift, marker) packing parameters are probed from mortie at
construction and span-checked, so a packing change fails loudly instead of
mis-ranking.
Edges to know about:
selaccepts packeduint64words, decimal strings, or lists of either. Selections an interval set cannot represent (scalar collapse, non-monotonic picks) degrade by dropping the lazy index with a warning — never by wrong answers.- Mixing moc- and pandas-indexed datasets in one alignment raises
pointedly: reopen both with the same
index_kind. xr.concatof moc-indexed datasets works when their domains are disjoint and in ascending word order (the batch-sweep / AOI-tile case); overlapping, interleaved, or reversed domains raiseNotImplementedErrorpointing toindex_kind="pandas", whose materialized coordinate concatenates arbitrarily.- Mixed-order (pyramid) domains are out of scope for the lazy index:
an interval set is a rank space, and rank is only defined at one order, so
intervals-per-order is the named seam. This is no longer an upstream wait —
mortie's mixed-order kernels shipped in 0.9.1 and
issue #8 adopted them without
taking the seam. The values-level surfaces did move:
coverage.aoi_maskresolves each cell at its own order, so an undecoded mixed-order dataset masks correctly. What stays single-order is anything that binds a level — the moc interval set, theMortonIndexdomain contract (one order, and it islevel), and the fabricated NESTEDcell_idsview.
NESTED is fabricated, never stored
Under the morton-only storage decision
(englacial/zagg#262), the
packed morton word is the only stored cell identity; the HEALPix NESTED
cell_ids view is derived exactly from the words via mortie's
vectorized mort2healpix — so Python consumers keep NESTED for free
without the writer carrying a redundant array. open_hive's
fabricate_cell_ids="auto" keeps a stored cell_ids untouched where one
exists (dual-written stores) and fabricates on morton-only stores;
fabrication is a Python-side convenience view, and the fabricated ids are
bit-identical to what a dual-writing store would have stored.
One seam: NESTED ids above order 24 exceed the float64-exact integer range
(2**53), so they are unsafe as JS Numbers in browser consumers.
Fabrication above order 24 currently warns; the 29→24 clip policy lands
with the resolution-discriminator metadata on the zagg#262 thread.
The DataTree view: open_store
A multi-product store root (zagg D19, mortie spec §6.5) is a directory of
stores: each product lives under its own named prefix, and every product
subtree is a complete, unmodified morton-hive store. Two products of one
store are heterogeneous-schemas-by-construction — different variables,
different orders, different windows — which is exactly the
non-alignable-groups case xarray.DataTree exists for. open_store
returns that tree:
- The root node is empty — no variables, no coordinates, only
store-level attrs (
morton_hive_store: the store root and the product roster). A multi-product root carries no manifest of its own by design. - One child node per product, named by its product name, each holding
exactly the lazy Dataset
open_hivereturns for that product — same index posture, same read behavior, same issue-#4 empty-AOI contract, applied per node. "Lazy" isopen_hive's laziness, not a stronger promise: on the moc default the cell arrays are never read andmortonis fabricated on demand, while a node spanning several leaves concatenates their data variables at open (the tree layer adds no reads of its own). The product's D19semantic_hashrides on the node's attrs. - A bare single-product store is the valid degenerate form: a
one-child tree (the child named from the manifest's dataset
short_name), so tooling written against the tree shape works on any store. That name is derived, not published: the roster stays[](exactly whatlist_productssays) and the store attrs carrybare: True+node: <derived name>— soproducts=has nothing to filter on there and raises.
Which axes become nodes is a design ruling (zagg's O14, issue #15), worth knowing as a user because the answer is mostly no:
- Products → nodes. The only axis that is nodes today.
- The window axis is never nodes. Time stays a per-node dimension:
pass
window=and it scopes the time-windowed (morton-hive/2) products, while unwindowed products keep their whole (single) form — one call opens a store that mixes both. - The spatial digit axis is never nodes. Mirroring the hive digit tree as tree levels would put millions of nodes of metadata client-side for zero query power the MOC arithmetic doesn't already give.
- Resolution (pyramid order) is nodes — when the manifest declares them. A product carrying sweep-generated overviews grows one child per stored cell order (next section); the seamless mixed-order composite stays a computed view, never a node.
open_store is named for its scope, not its return type: open_datatree
is xarray vocabulary for zarr-native hierarchies, which the hive tree
deliberately is not (zagg's D12 interop hierarchy is xr.open_datatree's
to open, someday, as a derived cache — the MOC-first opener stays truth).
Resolution nodes
Status: implemented (phase 8b of issue #15, against the first real overview fixture from zagg's second-pass sweep — englacial/zagg#201, whose reader-facing rulings this section records — and the normative pyramid-block declaration of the zagg store specification §4 (englacial/zagg#340). The committed fixture is zagg-written end to end:
tools/generate_overview_fixture.py, production write path plus thesweep_overviewssecond pass, zagg sha recorded in the golden sidecar.)
When a product carries sweep-generated overviews, the resolution axis becomes a second node level under the product:
/ <- empty root (store-level attrs)
{product}/ <- today's product node
{order}/ <- one node per stored order, e.g. 8/, 6/, 4/
Node layout {product}/{order}
Order nodes are named by the integer cell order they store (the §4.4
constant-depth rule maps each declared ancestor order k to cells at
c - (s - k)). On an overview-carrying store the product node becomes an
empty intermediate carrying the product's identity attrs (semantic_hash,
the manifest summary); the source data becomes the source-order child —
the same lazy open_hive Dataset, plus per-object role entries (below) —
and each declared overview order with at least one stamped object becomes
a sibling node, riding the multiscale-DataTree conventions. A product
whose manifest declares no overview family keeps today's flat form,
unchanged. A zagg-pyramid/2 product (the fixed ladder) grows the same
level from pyramid_levels' resolution table — the §4.6 column-carried
leaf resolutions included — its children named o{cell_order} (o5; the
espg-ruled reader-side group grammar, shared with open_pyramid), each
the open_level Dataset for its cell order, with the normalized
declaration record under the product node's zagg_pyramid attr; decode=True is refused there (native
surfaces only, the englacial/zagg#550 ruling). Windowed products inherit window naming (D23): window= scopes
each order node to that window's {window}.zarr overviews, so one call
still opens a store mixing windowed and unwindowed products.
window= takes a declared window label only. The reserved all-time
token "all" is refused as a label: a windowed product's all-time folds do
exist on disk (pyramid.overview.all_time, spec §4.5 — all.zarr at each
ancestor node) but the source axis has no all-time leaf to pair them with,
so accepting the token would hand back one tree whose source order reports
0 cells beside overview orders summing every window —
open_store(..., window="all") raises and names the gap. all is
excluded from the window grammar forever (§4.2); the surface is its own
opt-in instead: all_time=True on open_overview_order / open_level /
open_pyramid (issue #31, the recorded option-(1) posture) opens the
cross-window folds as an overviews-only view — the source and §4.6
column levels are absent, honest about what is materialized — mutually
exclusive with window= and refused on an unwindowed store (whose artifacts
ARE its all-time folds, reached with window=None). The declaration gate
is open_pyramid's alone: it refuses all_time=True on a store whose
declaration never carried all_time folds, because the alternative there is
a childless tree of per-level warnings rather than a pointed answer. The
resolution openers keep the ordinary posture — what is declared says
nothing about what is on disk (zagg#381 point (11)) — so
open_overview_order / open_level read whatever all.zarr folds are
stamped, and degrade to their usual warned None when none are.
aoi= and window= scope rows, never the tree's shape and never the
answers below: an out-of-coverage AOI empties each node schema-correct
(issue #4) while source_orders / overview_orders keep reporting what the
store holds.
role is per object, never per node
zagg#201's ruling (5) refuses to let position — or any node-level summary — stand in for it:
The manifest's pyramid section additionally enumerates which orders carry overviews (a useful walker hint), but it cannot be authoritative per-object: an order can host both overviews and coarse source in sparse regions (D11), so walkers at overview-carrying orders still check
roleon open. Two artifacts, two jobs: MOC = source domain;role= per-object classification.
So the attr lives on each leaf, a closed two-value vocabulary:
"source" — writer-native data at that order; "overview" — a
sweep-generated coarsening of the source (regenerable, D9 cache class). It
never rides alone: #201/D11 make the source order and the
aggregation method mandatory companions in the same attrs, and those
are what an "is this comparable to my source data?" check actually reads.
An order node is in general a mixture of both roles — overviews
coarsened from finer regions sitting beside sparse regions written
natively at that order ("a shallow zarr may equally be coarse source")
— so a node-level role is at best a derived summary and would need a
third "mixed" state; this reader will not synthesize one. Selection
helpers (source-vs-overview, "finest at or above order k") key on per-leaf
role, never on node names and never on a node-level attr.
Concretely: every order node's dataset carries attrs["zagg_objects"] —
one entry per stamped object the open admitted, with the object's own
role (absence on disk reads as "source", per the spec) and, for
overviews, its full zagg_overview provenance block (the D11 companions:
source cell order, per-field class/method/nan_policy). The contract
is checked on open, with two severities split on §4.1's "overviews are
regenerable caches, never load-bearing… a reader MUST NOT require them":
- Uninterpretable cache object — a third
rolevalue, a missing or unknown-revisionzagg_overviewblock, a block withoutcell_order: the object is dropped with aUserWarningand the node keeps its remaining objects. Failing the wholeopen_storewould take the product's source nodes down with one stale cache object, which is exactly what §4.1 forbids; unstamped overview prefixes are already debris and skipped the same way (D4), exactly as for leaves. - Interpretable but wrong — a provenance block positively declaring a
cell_orderother than the node's: this raises. Those rows would be mis-ranked under the node's §4.4 coordinate, a wrong answer rather than a missing one, and an off-order fold indicts the sweep rather than one object.
Both severities are evaluated for every stamped object the order node
names, before any aoi scoping — so what a store reports about its own
integrity does not depend on the query that opened it.
Source is not a single order — uniqueness is per (shard, window)
D24 makes resolution heterogeneity regional, so "source" leaves can
sit at several orders in one product tree
(zagg D24,
§7):
one product tree may carry regionally heterogeneous resolution (e.g. o19 cells in polar shards, o17 mid-latitude)
(2) Per (shard, window) there is one resolution at a time: heterogeneity is regional, across shards.
A product with o19 polar shards and o17 mid-latitude shards therefore has
{product}/19 and {product}/17 carrying role: "source" leaves —
"the source node" is not a well-formed request. Helpers are defined over
the set of source orders: source_orders / overview_orders (every
stored order carrying at least one object of that role, keyed on the
per-object entries) and finest_source_at ("the finest source order at or
above order k" — at-or-coarser numerically), never "the one source node".
These answer about the store, not about the query: they are unaffected
by the aoi/window the tree was opened with, and they are defined on a
flat (declared-off) product node too, where the product node itself holds the
data — source_orders is its own cell order, overview_orders is (). An
empty overview_orders therefore always means "this product stores no
overviews", never "the AOI missed them"; and a flat node's () overviews
sits beside a real source order rather than reading as "no data here".
This is the same fact the computed-compose rule below rests on (a
D24-heterogeneous product has no complete single-order Dataset precisely
because its source spans orders).
Per-node variable sets differ in both directions
Sibling order nodes have heterogeneous variable sets, and readers must treat per-node schemas as independent (they already are across products). zagg#201 ratified two mechanisms, one per direction — the recorded ruling and the full option space:
Absent above its native order (option A — the default). Non-composable
(none-class) fields, roster-kind ragged among them, are excluded per
field: "composable fields roll up, none fields exist only at native
resolution." A variable present at a source order may simply not exist at
coarser orders. Absence of a variable at an order is an answer, not an
error.
Present only above its native order (option B — the ratified opt-in). An
explicitly declared derived summary is available as the opt-in: e.g. an auto-digest of a roster field's raw values under a different field name at overview orders, so overview schema never silently differs from source
so an overview node can carry X_digest / X_hist / X_count that exist
at no source order — plus, under the espg-flagged opt-in Phase F,
seeded-reservoir sample fields marked sampled: k with their seed rule in
attrs. tree[product]["18"].ds having a variable
tree[product]["20"].ds lacks is the expected case, not corruption.
Reader-facing corollary: those declarations live in the manifest's
pyramid block, never the semantic core — "two products differing only
in overview-summary declarations are the same product" (D24) — so the
semantic_hash on a product node says nothing about which overview
variables exist. Ask the node, not the hash.
The computed-compose rule
A D24-heterogeneous product (regionally mixed cell orders) has no
complete single-order Dataset: the seamless order-k view is a computed
compose — coarsen-where-finer / passthrough-where-equal over the stored
orders, built on the truncation-join arithmetic (parent_cells /
join_coarse) — and is never materialized as a tree node. Nodes hold
stored data; composed views are functions of the tree.
Node discovery is manifest declaration
The once-open 8b question resolved with the
zagg#340 store
specification (§4.5): the reader binds pyramid.overview.orders and
nothing else. [] — or no pyramid block at all (pre-pyramid manifests)
— is the declared-off form and means no other key of the block may be
assumed; when orders is non-empty, spacing/all_time/fields must
all be present (additional keys are tolerated — the sweep's materialized
actuals, per-field nan_policy, summarize). The other candidate
mechanisms lost on spec-first grounds: a listing walk is what the
whole convention exists to avoid, and a MOC extension (per-order
coverage MOCs, the intervals-per-order seam of
issue #8) stays unbuilt.
Declaration names the orders; the objects at each order are then named arithmetically. Per zagg#201's ruling (5) the root MOC is the source domain —
coverage.mocis built from the dispatcher's source-shard completion list (D8), so arithmetic readers driven by manifest+MOC never visit overview nodes at all — zero opens spent filtering.
— which keeps arithmetic source reads overview-blind (a feature), and gives the order-node opener its candidates for free: the source shards coarsened to the declared ancestor prefix name every node that could hold an overview, one stamp GET each — a different code path from the leaf arithmetic phase 8a wraps, sharing its tiers (D4 stamps, the issue-#4 empty posture per node, zero chunk reads on the moc default). Two consequences worth knowing: without a usable root MOC the order nodes are omitted with a warning (overviews are D9 regenerable caches, never load-bearing — the source child still opens via the discovery walk, which skips the non-decimal overview basenames); and a declared order with no stamped object anywhere (not yet swept, or deleted) is likewise omitted loudly rather than fabricated empty.
A third omission, for the same reason: a store declaring path_grouping > 1
(D21). A grouped tree's directories exist only at multiples of the grouping,
so an ancestor order that is not a group boundary has no node in it —
4/33 would be a truncated group component where the tree's component is
331 — while zagg's sweep writes ancestor nodes one component per digit
regardless of grouping. There is no settled path to name, so the reader
names none: the order nodes are omitted with a warning and the source node
stands. Every store written today is path_grouping: 1, where the per-digit
form is the tree's own node path.
One resolution, one Dataset: open_level
Status: implemented (issue #37 — the per-order data model behind issue #36's mechanism; the espg ruling on that thread is "one dataset per order").
A pyramid store's reader-facing axis is resolution, not node order: a
reader picks a cell order and reads it (zagg spec §4). pyramid_levels
turns the manifest declaration into that table — one record per
addressable cell order, each naming the artifact kind that materializes it
(source at native resolution, overview at ancestor nodes, column for
a /2 declaration's §4.6 leaf tier; the vocabulary mirrors zagg's
zagg-multiscales/1 convention entries) — and open_level dispatches on
it, so whichever kind owns the level the caller gets the same Dataset
shape:
- the 1-D
cellsdim, nested-ascending in packed-word order; - the
mortoncoordinate (packeduint64words at the level's cell order; identity is morton, never lat/lon) with the lazyMortonMocIndexby default, plus the fabricated NESTEDcell_idsview; - dense fields as dense variables; ragged digest fields as their encoded
vlen-bytes variables on the same axis, the
raggedattrs block riding verbatim (decode viaparse_ragged_attrs+decode_cell, or the store-addressedread_ragged); attrs["morton_hive"](the level summary),attrs["zagg_objects"](the per-artifact roster with each object'szagg_overview/zagg_columnprovenance block verbatim — fold regime, generation, and any stampeddemotionsrecords: a pre-spec key tracking the open englacial/zagg#557, whichlevel_demotionsflattens from the sweep-writtenzagg_overviewblocks without validating it and without claiming a spec for it), andattrs["zagg_level"](the level record plus the declared field classes and fold regime).
Both grammars are first-class: a /1 store's levels are its native order
plus the constant-depth overview orders (the live public stores' shape),
and a /2 store adds the column-carried leaf resolutions and the fixed
every-order ladder. open_level deliberately exposes no decode= (new
pyramid surfaces are native moczarr only — the englacial/zagg#550 ruling).
The multi-order assembly: open_pyramid
Status: implemented (issue #36, the #36b slice — the DataTree over
pyramid_levels, built on the #37 per-level block).
open_pyramid(store_root) assembles the whole ladder as one
xarray.DataTree: an empty root carrying the declaration —
morton_hive (the manifest summary, plus semantic_hash when recorded),
zagg_pyramid (the normalized pyramid_declaration record from the
NORMATIVE pyramid block), and the manifest's §4.9 multiscales
discovery mirror verbatim when one is recorded (never consulted, never
re-derived — the pyramid block wins by §4.9's own precedence rule) — and
one child group per materialized resolution, finest first, named
o{cell_order} (o5 for the level storing order-5 cells — attribute
access works, tree.o5.ds, and the name is an unambiguous string; the
oN spelling is reader-side tree vocabulary only, store-side wire paths
stay numeric, 19/... on disk), each holding exactly the open_level
Dataset for that resolution. Declared-but-unmaterialized levels are
omitted with their opener's warning (declaring is free, sweeping is the
operational decision); aoi= scopes rows per level, never the tree's
shape; levels= bounds the assembly on a wide ladder (a /2 store
declares every order down to 0). A declared-off store is the valid
one-level degenerate form. The seamless mixed-order composite stays a
computed view, never a node, and zoom-out ergonomics (a default coarse
level for a first render) stay
issue #21's.