Ancestor-subzone Zarr layout: one chunk is one subtree - #63
Conversation
Zarr chunks are uniform by format, so a chunk grid that follows the tree exactly is not expressible along a one-dimensional cell axis: an IGEO7 pentagon's subtree holds p(d) = (5*7^d + 1)/6 cells where a hexagon's holds 7^d, and no single chunk length lands on both. The ancestor-subzone layout buys tree-aligned chunking by spending a dimension on it — subzone position within one ancestor's subtree, then the ancestor, one chunk per column — so a chunk is a subtree, an ancestor nobody wrote is a chunk that was never stored, and a reader gets the tree's own irregular chunking back. This is the half that needs no store: `SubzoneLayout` and the mapping in both directions (`subzoneindex`, `positionindex`, `columnpositions`), the run planner that turns a cube's cell axis into whole columns and refuses a partially covered one, its inverse `subzone_cellvector`, and the attribute block a store carries. All of it is `ancestor`/`cellposition`/ `descendant_range`, which are O(level) digit arithmetic wherever `has_sorted_subtrees` holds, so a store of 10^12 cells is described by three integers and never has a level-L id vector materialized to be addressed. The vocabulary is a `dggs` object with everything the layout adds nested under `subzone_layout`, and no `zarr_conventions` declaration: this is not the one-dimensional layout that convention describes, and claiming to be it would send a convention-aware reader down a path that cannot open the store. Column order inside a subtree is what OGC API-DGGS calls sub-zone order. `subzonestore` and `dggwrite!` join `dggread`/`dggwrite` as stubs; their methods arrive with the Zarr extension. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
The Zarr half of the layout: `subzonestore` creates the group, its (capacity, ncolumns) arrays chunked one column each, and its attributes, once; `dggwrite!` fills columns afterwards, one ancestor cell's subtree or a whole cube at a time. `dggwrite(dest, cube; layout = :subzones, ancestor_level = k)` is those two calls and nothing else, so the incremental path is not a second implementation of the one-shot path. A column is one chunk is one file, and a column write rewrites nothing shared — not the attributes, not the consolidated metadata, not a manifest — so the production run may write disjoint columns from as many tasks as it likes with no coordination. The suite asserts that: one new file per column write, and every file that was there before untouched to the mtime. Reading fakes the two-dimensional store into the one-dimensional cell axis it stands for. `dggread` recognizes the layout from its own attribute before the conventions are asked, and hands back a `Cells` dimension over a `SubzoneCellArray` — a `DiskArrays.AbstractDiskArray` whose `eachchunk` is `IrregularChunks` of the real column lengths, 7^d for a hexagon ancestor and p(d) for the twelve pentagons. That is the chunking Zarr's own grid could not hold, and anything that reads by chunk now reads whole subtrees with the padding already dropped. A read inside one column is one chunk read; one spanning columns is one per column. The default view is the whole level, since a column nobody wrote is not absent from such a store — it reads back as fill; `ancestors` restricts it to the columns named. DiskArrays becomes a direct dependency: the wrapper lives in the extension, but an extension may only load what the package declares, and Zarr brings DiskArrays in either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
What the layout buys, what it costs — a column is written whole — and where each verb lives, alongside the one-dimensional layout it sits beside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
`dggwrite(dest, stack; layout = :subzones)` goes through the same `subzonestore`/`dggwrite!` pair as the array form, one layer per element type, and nothing asserted that until now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
| Everything above keeps a store's cell axis in ONE dimension and chunks it by | ||
| cutting that axis into equal pieces. Zarr chunks are uniform by format, so a | ||
| chunk grid that follows the TREE exactly is not expressible that way: an IGEO7 | ||
| pentagon's subtree holds `p(d) = (5·7^d + 1)/6` cells where a hexagon's holds | ||
| `7^d`, and no single chunk length lands on both. | ||
|
|
||
| The ancestor-subzone layout buys tree-aligned chunking by spending a dimension | ||
| on it. Its data arrays are two-dimensional — subzone position within one | ||
| ancestor's subtree, then the ancestor — chunked one column per ancestor, so a | ||
| chunk *is* a subtree. Position within a column is what OGC API-DGGS calls the | ||
| **sub-zone order** of a parent zone, and it is ascending cell id here. Three | ||
| things follow, and they are the reasons to reach for it: |
There was a problem hiding this comment.
wall of text. also, this is a bit of a hack so should go in a separate file, once zarrv3 with the right stuff lands we'll just get rid of it
There was a problem hiding this comment.
Moved out of store-io.md into its own page, docs/src/api/subzone-layout.md, which opens with an "Interim" admonition saying it exists only because Zarr chunks are uniform and goes away once variable chunk sizes land; the prose is roughly half its old length. The implementation was already in its own files (src/io/subzones.jl and ext/DiscreteGlobalGridsZarrExt/subzones.jl) — the former's header now leads with the same interim note. 13ebe23
| The spec's own keys keep their meaning — `name` and `refinement_level` describe | ||
| the cells the store holds, whichever shape it holds them in — and everything | ||
| that has no `zarr-conventions/dggs` spelling lives inside one nested object | ||
| rather than being sprinkled through the one the schema validates. No | ||
| `zarr_conventions` declaration is written: this store is not the convention's | ||
| one-dimensional layout, and claiming to be it would send a convention-aware | ||
| reader down a path that cannot open it. It fails with "no convention detected" | ||
| instead, which is true. |
There was a problem hiding this comment.
subzone_attrs is down to eight lines: what the attribute object holds, why there is no zarr_conventions key, and what coordinate is for. Also cut the file header and the other new walls in this diff (the ext header, SubzoneCellArray, the subzonestore stub). 13ebe23
The layout is an interim workaround for uniform Zarr chunks, so its docs move out of store-io.md into api/subzone-layout.md and say so; src/io/subzones.jl's header leads with the same note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
Brings up the [sources] fix from PR #62: GeometryOps 2825c476647cfe6791dbb89fffb8592697b46995 -> 36c853e0de865251e1395ff01e9d34cda9e9bfa8 in all five blocks, so a fresh instantiate of this branch resolves one GeometryOps. ConservativeRegridding stays at 6a4b997.... Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
Brings up the [sources] fix from PR #62 through #63: GeometryOps 2825c476647cfe6791dbb89fffb8592697b46995 -> 36c853e0de865251e1395ff01e9d34cda9e9bfa8 in all five blocks, so a fresh instantiate of this branch resolves one GeometryOps. ConservativeRegridding stays at 6a4b997.... Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
Stack position: 2 of 4 —
main← #62claude/dgg-source-subtree← #63claude/subzone-store← #64claude/copdem-production← #65claude/perf-ladder(draft)This is a stacked PR chain. Each PR's base is the previous branch, so each diff shows only its own commits. Do not squash-merge out of order; merge bottom-up and retarget children after each merge.
What
A second Zarr store layout,
layout = :subzones: a 2-D ancestor-subzone array in which dim 1 is the subzone position1:capacitywithin one subtree and dim 2 is the level-Laancestor, chunked(capacity, 1)— so one Zarr chunk is one level-Lasubtree. A level-L cell set becomes columns of7^(L−La)cells per level-Laancestor.Plus the verbs to fill it incrementally (
subzonestore,dggwrite!) and aDiskArraysfacade that reads it back as the one-dimensional cell axis it stands for.Why the existing 1-D layout could not do this
Zarr chunks are uniform by format, so a chunk grid that follows the tree exactly is not expressible along a 1-D cell axis: an IGeo7 pentagon's subtree holds
p(d) = (5·7^d + 1)/6cells where a hexagon's holds7^d, and no single chunk length lands on both. The subzone layout buys tree-aligned chunking by spending a dimension on it.There is a second, harder motivation recorded in
regrid-notes/2026-08-20-copdem-zarr-spike.md§3:_cellaxis/_rawidsmaterializes the raw-id vector for every encoding (:ranges,:dense,:implicitalike). A global IGeo7 L12 store is 138,412,872,012 cells — 1.107 TB for the id vector alone, on a 751 GB machine, plus ~1.4e11rawid/convertcalls. This layout never materializes a level-L id vector at all: mapping in both directions isancestor/cellposition/descendant_range, O(level) digit arithmetic whereverhas_sorted_subtreesholds, so a store of 10^12 cells is described by three integers.Three properties it buys
DiskArrays.IrregularChunksof the real column lengths —7^dfor a hexagon ancestor,p(d)for the twelve pentagons — with padding dropped. Anything that reads by chunk now reads whole subtrees. A read inside one column is one chunk read; one spanning columns is one per column. Opening is O(1) even at L13.The four commits
39b1094— the arithmetic and vocabulary,src/io/subzones.jl(624 lines), Zarr-free and system-generic.SubzoneLayoutand the mapping both ways (subzoneindex,positionindex,columnpositions); the run planner that turns a cube's cell axis into whole columns and refuses a partially covered one; its inversesubzone_cellvector; the attribute block a store carries.subzonestore/dggwrite!joindggread/dggwriteas stubs here.05f8af0— the Zarr half,ext/DiscreteGlobalGridsZarrExt/subzones.jl(626 lines).subzonestore(dest, system, level; …)creates the group, its(capacity, ncolumns)arrays chunked one column each, and its attributes, once;subzonestore(dest)reopens.dggwrite!(store, ancestor, values; var)fills columns afterwards.dggwrite(dest, cube; layout = :subzones, ancestor_level = k)is those two calls and nothing else, so the incremental path is not a second implementation of the one-shot path. Reading:dggreadrecognizes the layout from its own attribute before the conventions are asked, and returns aCellsdimension overSubzoneCellArray <: DiskArrays.AbstractDiskArray{T,1}. DiskArrays becomes a direct dependency (an extension may only load what the package declares; Zarr brings it in either way).c784f23— the store-IO reference section: what the layout buys, what it costs (a column is written whole), where each verb lives, alongside the 1-D layout it sits beside.86f3174— covers the stack form of the one-shot write, which goes through the samesubzonestore/dggwrite!pair, one layer per element type, and was previously unasserted.On-disk vocabulary
One group attribute
dggswith everything the layout adds nested undersubzone_layout:layout: "ancestor_subzone",version: 1,ancestor_level,ancestor_dimension,ancestor_count,ancestor_order: "ascending_id",subzone_count,padding: "trailing_fill",padding_fill_value,chunk_shape.ancestor_count/subzone_countare checked against the grid's own arithmetic on read, not believed.Deliberately no
zarr_conventionsdeclaration. This is not the one-dimensional layout that convention describes, and claiming to be it would send a convention-aware reader down a path that cannot open the store; a foreign reader instead gets a clean "no convention detected". Column order inside a subtree is what OGC API-DGGS calls sub-zone order.layoutis orthogonal toencoding(store shape vs coordinate shape) and is not aCellEncoding.Pentagons — the one structural caveat
p(d) = (5·7^d + 1)/6. Confirmed bydescendant_rangeat La=2, L=4: exactly 12 of 492 columns are short — 41 cells against capacity 49 — and they are exactly the digit-0 chains from the twelve root cells. Padding is asserted: 41 real values then 8 NaN, and the axis returns 41 cells.valuesmust becolumnlength(layout, column)long; handing7^dto a pentagon column raises anArgumentErrorrather than silently overwriting padding.Sizing
Chunk size is
capacity × sizeof(T)— the only thing the layout constrainsLaby.The optional
ancestor_cell_idscoordinate is on by default and costs 9.4 MB at La=6, written once at creation; this package never reads it back.capacityis measured once at creation (onedescendant_rangeper level-La cell — a fraction of a second even at 1.2 M columns); passcapacity = 7^dto skip that pass.Deliberately not built
Multi-dimensional layers (a time/band cube is refused by name), written-column discovery or an index, a chunk cache in the wrapper (use
DiskArrays.cache), remote writes, and writing through the read view (refused, and points atdggwrite!). Coverage must be ancestor-snapped: a cube covering part of a subtree raisesDGGSFormatError(check = :incomplete_subtree).Tests
test/io/subzones.jl110 assertions (no Zarr) plustest/io/subzone_store.jl86 assertions (self-skipping when Zarr is absent) → io suite 937 assertions / 0 failures, 196 of them new. Whole package 987,456 pass / 17 broken / 0 fail.Notes
Full record:
regrid-notes/2026-08-20-copdem-zarr-spike.md§14. The La choice this layout is sized against is inregrid-notes/2026-08-20-la-choice.md(chunking is bitwise-neutral: La=5 vs La=6 vs La=7 over the same region gave max |diff| 0.000e+00 m, 218,491/218,491 bitwise equal — so La can be revisited later without invalidating output).🤖 Generated with Claude Code
https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2