Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion deployment/aws/lambda_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,10 +838,13 @@ def _handle_setup(event: Dict[str, Any]) -> Dict[str, Any]:
import numpy as np

from zagg.processing.raster import emit_raster_template
from zagg.time_axis import time_axis_dtype, time_encoding

store = open_store(event["store_path"], **_output_store_kwargs(event))
grid = from_config(config)
times_us = np.asarray(event["times_us"], dtype=np.int64)
# The wire carries plain ints; the cast is the config's declared
# time encoding (spec §8) — a toc word does not fit int64.
times_us = np.asarray(event["times_us"], dtype=time_axis_dtype(time_encoding(config)))
if times_us.size == 0:
# A zero-timestep template is degenerate: the arrays get a
# 0-length time axis no worker can slab-write into.
Expand Down
139 changes: 129 additions & 10 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

This page is the **normative record** of zagg's byte-level store conventions:
the ragged vlen-bytes layout, the t-digest payload bytes, the packed
composition word, the pyramid/overview declarations, and the O11 content-hash
recipe. It exists so an independent reader
composition word, the pyramid/overview declarations, the O11 content-hash
recipe, and the temporal declaration on a time coordinate. It exists so an independent reader
([moczarr](https://github.com/espg/moczarr)) can decode a zagg store from this
page and the committed conformance fixtures alone — no zagg import, no
reverse-engineering of `grids/base.py`
Expand Down Expand Up @@ -55,7 +55,7 @@ text marked *informative* explains or motivates and binds nothing.
explicit succession clause; readers add revisions, they never drop them.
- The committed conformance fixtures (§7) are part of the contract: a reader
implementation that reproduces the fixtures' expected decoded values and
content hashes conforms to §1–§3 and §5. zagg's own test suite asserts the
content hashes conforms to §1–§3, §5 and §8. zagg's own test suite asserts the
same expectations (`tests/test_spec_conformance.py`), so the spec, the
fixtures, and the shipping reader cannot drift apart silently.

Expand All @@ -68,6 +68,7 @@ Contents:
5. [O11 content hashes](#5-o11-content-hashes)
6. [`zagg-ragged/2` — the typed `vlen-ndarray` revision](#6-zagg-ragged2)
7. [Conformance fixtures](#7-conformance-fixtures)
8. [`zagg-toc/1` — the temporal declaration](#8-zagg-toc1)

---

Expand Down Expand Up @@ -1397,19 +1398,20 @@ stores may deliberately stay `/1` for vanilla-zarr openability).
**Status: contract.** The committed stores under
[`tests/data/spec/`](https://github.com/englacial/zagg/tree/main/tests/data/spec)
are part of this specification: a reader implementation that reproduces
their expected decoded values and content hashes conforms to §1–§3 and §5.
They are generated by
their expected decoded values and content hashes conforms to §1–§3, §5 and
§8. They are generated by
[`tools/generate_spec_fixtures.py`](https://github.com/englacial/zagg/blob/main/tools/generate_spec_fixtures.py)
through zagg's **production write path** (manifest, sharded leaf template,
dense + ragged writes, coverage sidecar, commit stamp), so writer↔spec
drift fails zagg's own suite (`tests/test_spec_conformance.py`) on
whichever side moved. moczarr vendors the same fixtures for its parity
gates (espg/moczarr#19/#20).

Four tiny single-shard hive stores plus one manifest-only declaration, all
Five tiny single-shard hive stores plus one manifest-only declaration, all
on the same deliberately small geometry — shard order 4, inner-chunk order
5, cell order 6 (16 cells, K = 4 inner chunks of 4 cells), sharded (the
hive default):
hive default; `raster_toc/` is the one exception — a `(time, cells)`
product is never sharded, §8/#247):

- **`minimal/`** — one *unlocated* digest field (`h_tdigest`) plus `count`.
The smallest thing that is a conforming store.
Expand Down Expand Up @@ -1461,6 +1463,20 @@ hive default):
arrives with the sweep-side fixtures of
[#384](https://github.com/englacial/zagg/issues/384).

- **`raster_toc/`** — the §8 temporal declaration surface: one raster
`(time, cells)` hive leaf whose `time` coordinate is `uint64` toc words
carrying `temporal: {"spec": "zagg-toc/1", "shape": "axis", …}` and no CF
`units`/`calendar` attrs, beside `morton` and two band arrays. Its axis
mixes both word variants deliberately — one single-item timestep encoded
as an exact **timestamp** word and two multi-item acquisition groups
encoded as **range** words — so a reader that implements only one variant
fails a §7 fixture. `raster_toc.expected.json` records the words as
decimal strings (JSON numbers cannot carry `uint64` faithfully) together
with the `(start, end)` nanoseconds a conforming decode yields and the
real acquisition spans they must contain, which is the §8 conservative
containment claim pinned on committed bytes. The other four fixtures,
which carry no `temporal` key anywhere, are the absent-key ⇒ legacy pin.

`minimal/` and `kitchen_sink/` pin the layout edge cases a reader must
handle (`column/`'s leaf is `minimal/`'s, so it pins them again): inner chunk
ordinal 2 is **empty** (absent from the shard index — the §1.5 sentinel, and
Expand Down Expand Up @@ -1506,9 +1522,9 @@ itself on both sides, which is also the only mechanism that catches a future
zagg↔moczarr divergence (neither side's fixture can: espg/moczarr#23).

**Conformance criteria for an external reader**: decode every ragged array
per §1–§2 and the composition array per §3, reproducing the expected
decoded values exactly (byte-exact float32/uint64 — no tolerance), and
reproduce `content_hashes` per §5. zagg's own suite additionally decodes
per §1–§2, the composition array per §3, and the declared time coordinate
per §8, reproducing the expected decoded values exactly (byte-exact
float32/uint64 — no tolerance), and reproduce `content_hashes` per §5. zagg's own suite additionally decodes
the shard objects with **spec-text-only** decoders (struct + zstd, no zagg
read path) to prove the byte recipes in §1.4/§1.5 are sufficient on their
own.
Expand All @@ -1521,3 +1537,106 @@ principle as §5). In the committed D20 sidecar
`cells_with_data` are pinned: `timestamp`, `zagg_version`, `run_id` and the
run counters are **informative provenance**, they churn on every
regeneration, and conformance never asserts them.

---

## 8. `zagg-toc/1`

**Status: contract** ([issue #443](https://github.com/englacial/zagg/issues/443)
— the first shape of the temporal series,
[#410](https://github.com/englacial/zagg/issues/410)).

An array that carries **time values** declares how to read them under the
**`temporal`** attrs key on that array. The key is spec-owned: the writer
stamps it from the config's declared encoding, never author-transcribed
(§1.2's reserved-key discipline, extended to the time coordinate).

**An absent `temporal` key MUST be read as the legacy encoding** — signed
`int64` microseconds since `1970-01-01T00:00:00` UTC, self-described by the
CF `units`/`calendar` attrs the writer stamps beside it. Every store written
before this revision is conformant verbatim, no byte rewritten, and a reader
MUST NOT refuse a store for lacking the declaration.

```json
"temporal": {
"spec": "zagg-toc/1",
"shape": "axis",
"epoch": "1850-01-01T00:00:00",
"timescale": "gps-continuous",
"quantum_start_ns": 2147483648,
"quantum_end_ns": 4294967296
}
```

- **`spec`** — the convention revision. Readers MUST strict-check it: an
unknown or future revision raises, never half-parses under a guessed
layout.
- **`shape`** — where the words sit relative to the store's cells.
**`"axis"`** is the only value this revision defines: the declaring array
**is** the time coordinate of a `(time, cells)` product — one word per
timestep, row-aligned with the leading axis of every `(time, cells)` array
in the same group. The discriminator exists so a later per-cell or
per-centroid temporal companion declares itself in *this* grammar rather
than a parallel one; a reader MUST refuse a `shape` it does not implement.
- **`epoch`** / **`timescale`** — the word grammar's fixed time origin and
time scale. They are properties of the grammar, not writer choices; a
reader MUST refuse a declaration whose values differ from the grammar it

@espg espg Aug 17, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

The block ships the four per-store guards the #410 plan explicitly ruled out, and omits the one field it ruled in — and it is committed fixture bytes. The correction comment closing the #410 plan pass (#410 comment, 2026-08-17T00:35Z) says:

The declaration design also simplifies per espg's question: toc pins its epoch and quantization in the word grammar itself, so the zagg declaration needs only {shape, versioned grammar citation} — no per-store epoch/quantization guards.

What landed is {spec, shape, epoch, timescale, quantum_start_ns, quantum_end_ns}: epoch/timescale are a per-store guard (this section MUST-refuses a mismatch, and temporal_declaration raises), and the two quanta are per-store echoes of grammar constants. There is no versioned grammar citation in the block — spec: "zagg-toc/1" versions zagg's section, not the mortie grammar it defers to, and the mortie version appears nowhere in the stamped attrs (see my other comment: the prose citation is currently unversioned and 404).

The PR body presents this as settled ("echoed for reader self-description … and MUST-refused on mismatch, not re-specified") but does not surface it as a divergence from the plan, so it is not covered by "Questions for review" (5) which is only about the shape token.

Concretely, the divergence has teeth in both directions:

  • The guards are self-fulfilling. A writer stamps these from mortie.toc's own constants, and a reader refuses anything else. So they can only ever fail if a store was written by a different grammar — which the spec marker already gates. The cost is not zero: they are in the fixture bytes and in the O11 hash (6/time attrs are part of the leaf), so a future mortie that changed a quantum would force a §8 revision and a fixture regeneration that the spec marker alone would have handled.
  • The omission is the load-bearing half. With the grammar deferred to a non-frozen API page, a mortie_version (or a grammar: "mortie/toc@0.9.6") in the block is what would let moczarr pin what it decoded against. That is what "versioned grammar citation" appears to mean, and it is the field that is missing.

Not asking for a redesign on my say-so — but this is a spec-owned block going into committed fixture bytes right before #410's other shapes reuse it, so the divergence should be either reconciled or recorded as a deliberate overrule under "Questions for review".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Reconciled to the ruled shape, not recorded as an overrule — 97c69fd0. You read the correction comment correctly and the block had diverged from it in both directions.

The declaration is now exactly {spec, shape, versioned grammar citation}:

"temporal": {
  "spec": "zagg-toc/1",
  "shape": "axis",
  "grammar": "mortie/toc@0.9.6"
}

The four guard keys (epoch, timescale, quantum_start_ns, quantum_end_ns) are gone from the normative grammar, from the stamped attrs (time_axis_attrs), and from the read-side check (temporal_declaration no longer compares epoch/timescale — it strict-checks spec, shape, grammar). TOC_TIMESCALE is deleted; TOC_EPOCH survives only as the internal guard that refuses a time the words cannot represent, and is documented as such:

#: The cited grammar's time origin. A grammar property, not a declared key
#: (#410 ruled out per-store epoch/quantization guards); kept here only to
#: refuse a time the words cannot represent.

On the missing half. grammar is a fixed token of this spec revision, deliberately not mortie.__version__ at write time — a dynamic stamp would move every store's bytes (and the §5 hash) whenever a floor moved, reintroducing the exact cost you flagged. It cites the release whose reference §8's prose pins, and mortie#193's frozen section becomes the referent when it lands. A reader MUST refuse a grammar it does not implement.

The removal is justified in the spec rather than left as a silent deletion:

The epoch, the timescale, and the range variant's rounding quanta are properties of the cited grammar and are deliberately NOT echoed here. A store cannot re-base them — a differing origin would be a different grammar, which is precisely what grammar (and spec above it) already discriminates — so a per-store copy could only ever restate a constant, while entering the committed conformance bytes and the §5 content hash and forcing a fixture regeneration for any upstream constant that changed.

I also added the informative-keys escape hatch the ruling implies, since dropping the guards otherwise leaves no room for source-time lineage later: a writer MAY add non-normative keys, and a reader MUST ignore unrecognized ones rather than refuse the store. Pinned by test_informative_keys_are_ignored_not_refused.

Fixture regeneration. --only raster_toc regenerated; the diff is attrs-only, three keys removed and one added, in two files:

  • tests/data/spec/raster_toc/1/1/2/1/3/11213.zarr/6/time/zarr.json — the temporal block;
  • tests/data/spec/raster_toc.expected.json — its time_attrs mirror.

Plus the two informative written_at/generated_at stamps §7 already declares non-asserted.

No content hash movedFROZEN_COMBINED["raster_toc"] and FROZEN_ARRAYS[("raster_toc", …)] are untouched, and TestTemporalDeclaration::test_frozen_digests_pin_the_recipe passes on the regenerated bytes. So the §5 recipe digests array data, not array attrs: the "they are in the O11 hash" half of your cost argument turns out not to hold. The "they are in the committed fixture bytes" half does, and stands on its own.

Test side: test_declaration_grammar_and_dtype now asserts the whole block by equality (== {"spec", "shape", "grammar"}) rather than key-by-key, so a re-added guard key fails immediately instead of passing unnoticed; test_rebased_epoch_refused becomes test_uncited_grammar_refused; test_toc_stamps_the_declaration_and_no_cf_attrs likewise asserts the exact block, with a new test_the_cited_grammar_is_the_one_this_reader_decodes_with keeping the fixed citation honest against the constants the decode actually runs on.

Disclosed in the PR body under a new "Fixture diff" heading, and the divergence-from-plan item is dropped from "Questions for review" — it is no longer a divergence.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Follow-up on this thread, since the block you reshaped is the block that just moved again: espg ruled both token spellings in session on 2026-08-17, folded in b932fa11.

  • shape: "axis""coordinate" — the raster time array is the CF/xarray coordinate variable of the time dimension, and the downstream consumers (xdggs, moczarr's DataTree model) live in that vocabulary.
  • grammar: "mortie/toc@0.9.6""mortie-toc/1" — a grammar revision token in the ecosystem's {name}/{major} style (zagg-ragged/1, morton-hive/2), so store bytes never move when documentation moves. This is the durable answer to your original finding here: the mike-versioned API URL is now explicitly §8's prose documentation pointer (it stays release-pinned, and swaps to mortie's frozen spec section when Spec: normative section for the toc word grammar (frozen-for-1.x) espg/mortie#193 lands), while the stored token stays put across both.

The same ruling generalized §8's preamble to the domain-neutral form: {spec, shape, grammar} is defined once as a word-typed coordinate declaration with shape vocabulary {"coordinate", "per-cell", "per-centroid"}, then instantiated here for the temporal domain, with #410's temporal companions and the located mortie-morton/1 family named as non-normative forward instantiations (and the store's primary morton axis explicitly out of scope — it stays on the morton-hive/{1,2} manifest grammar + dggs attrs).

Fixture regenerated --only raster_toc: the entire diff is those two attrs lines in 6/time/zarr.json and their mirror in raster_toc.expected.json. No content hash and no frozen literal moved (§5 digests array data, not attrs). Disclosed in the PR body under "Regenerated for the ruled tokens", and body question (5) is marked resolved.

implements (a differing epoch is a future revision, not a re-basing hint).
- **`quantum_start_ns`** / **`quantum_end_ns`** — the range variant's
outward-rounding grids in nanoseconds, echoed so a reader can state its
own decode tolerance without hard-coding the grammar's constants.

### The word grammar is mortie's

A `zagg-toc/1` value is a mortie **toc word** (temporal order coverage): one
`uint64` that is a tagged union of an exact nanosecond **timestamp** and an
outward-rounded, conservative **range**, on a continuous, leap-free,
GPS-aligned timescale with a fixed `1850-01-01T00:00:00` epoch. The bit
layout, the flag position, the unsigned sort order, and the semilattice
merge law are normative in mortie's
[`mortie.toc` reference](https://espg.github.io/mortie/api/toc/) and its

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

The one normative pointer to the toc word grammar is a 404, and the §2.2 analogy it leans on does not hold. §7 says an external reader decodes "from this page and the committed conformance fixtures alone". For §8 that reduces to this link, and it does not resolve:

$ for u in .../mortie/api/toc/ .../mortie/latest/api/toc/ .../mortie/0.9.6/api/toc/; do curl -sSo /dev/null -w '%{http_code}\n' -L $u; done
404
200
200

mortie's docs are mike-versioned (https://espg.github.io/mortie/versions.json lists dev, 0.9.8/latest, 0.9.7, 0.9.6, …); the unversioned /api/toc/ path was never published. https://espg.github.io/mortie/0.9.6/api/toc/ resolves and is also versioned, which is what the pyproject floor (mortie>=0.9.6) already pins — I verified mortie/toc.py and src_rust/src/toc.rs are byte-identical at 0.9.6 and 0.9.8, so pinning 0.9.6 in the link costs nothing.

Second half, and the reason a bare link swap may not be enough: the sentence claims this is "exactly as §2.2 defers the morton word's layout to mortie's specification §1/§4". §2.2 defers to mortie's docs/specification.md, whose §10 "Frozen for 1.x" enumerates §1/§2/§4/§5/§6/§7/§8/§9 as immutable within the major version. That page contains zero occurrences of toc:

$ gh api repos/espg/mortie/contents/docs/specification.md?ref=0.9.8 --jq .content | base64 -d | grep -ic 'toc'
0

So §8 defers its entire word grammar to an mkdocstrings page generated from a module docstring — not a frozen normative section — while citing the frozen-section precedent as if they were the same class of reference. The docstring is careful and says "normative (words persist on disk) and pinned by golden fixtures", but nothing in mortie's 1.x freeze covers it.

Suggest: (a) pin the link to 0.9.6 (resolves, and matches the dependency floor), and (b) either soften the §2.2 comparison to "as §2.2 defers … , here to mortie's mortie.toc reference and its golden fixtures", or raise a mortie issue to add a spec section for toc and cite that once it exists.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Both halves folded in 03786bc2 — the link swap alone was not enough, exactly as you said.

(1) The URL is now release-pinned. https://espg.github.io/mortie/0.9.6/api/toc/ (verified 200; the unversioned path still 404s), with the reason stated in the page so a future editor does not "tidy" it back to the unversioned form:

The citation is release-pinned deliberately. Mortie's documentation is mike-versioned and the unversioned /api/toc/ path was never published, so only a versioned URL resolves; the pin is 0.9.6, the earliest release carrying the reference, and the page's normative words are unchanged through the current dependency floor.

I kept 0.9.6 rather than moving to 0.9.8 post-merge: your own verification is that toc.py/toc.rs are byte-identical across the two, so the earliest release carrying the words is the honest citation, and it stays valid if the floor moves again. The floor itself is now >=0.9.8 from the merge.

(2) The §2.2 precedent claim is gone, replaced with the true statement. The old sentence ("exactly as §2.2 defers…") is deleted; the section now says the opposite explicitly:

It is also not yet the same class of citation as §2.2's, and §8 does not claim it is. §2.2 defers the morton word's layout to mortie's docs/specification.md §1/§4 — sections that document's §10 "Frozen for 1.x" enumerates as immutable within the major version. Mortie's frozen specification contains no toc section: the grammar above is normative as a module reference pinned by mortie's own golden fixtures, which is a weaker guarantee. espg/mortie#193 tracks adding the frozen section; when it lands, this citation swaps to it and nothing else in §8 changes.

On the mortie#193 reference style: there was no established convention for a pending-upstream note in this spec, so I followed the nearest in-repo precedent — §4.3's "deferred as an opt-in phase… when it lands" and §5's "zagg's writer, when it lands (#342)" — a plain inline link plus the condition under which the text changes. Not a parenthetical, because the swap is a normative instruction to a future editor, not an aside.

Related: 0.9.6 is now also carried in the stamped attrs as the grammar key, per the declaration reshape in 97c69fd0 (your other finding), so a reader records what it decoded against rather than only the prose citation.

decision ledger ([espg/mortie#175](https://github.com/espg/mortie/issues/175)),
and are **not restated here** — exactly as §2.2 defers the morton word's
layout to mortie's specification §1/§4. What follows is zagg's half of the
contract.

### 8.1 `shape: "axis"`

**Contract.**

- The declaring array's element type is `uint64`, one word per timestep.
- A timestep whose real acquisition is a single instant MUST be encoded as a
**timestamp** word, exact to the nanosecond; a timestep covering a real
interval MUST be encoded as a **range** word whose envelope conservatively
contains that interval. A conforming writer therefore never widens an
instant into a range, and never narrows a real interval into an instant.
- Words are stored in **ascending acquisition order** — the order the time
axis has always had, and the order the `(time, cells)` slabs were indexed
by. Unsigned word order is order by conservative encoded *start*, so a

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

"except where two timesteps fall in one start quantum" is false whenever a catalog carries time_start, and the disagreement is unbounded — not one quantum. The stored order key is the group's earliest item datetime (raster_time_index: ordered = sorted(earliest, key=lambda k: (earliest[k], k))), but the encoded word's start comes from span[k][0] = min(time_start or datetime). Those are different quantities the moment a STAC item declares start_datetime earlier than its datetime — which is exactly the path this PR added and the dt-2 fixture group exercises.

Counterexample through the production function on this branch:

gran = [[
  {'id':'a','assets':{'red':'x'},'datetime':'2025-06-18T12:00:30+00:00','time_key':'A',
   'time_start':'2025-06-18T11:59:00+00:00','time_end':'2025-06-18T12:00:40+00:00'},
  {'id':'b','assets':{'red':'x'},'datetime':'2025-06-18T12:00:00+00:00','time_key':'B'},
]]
raster_time_index(gran)                  # -> {'B': 0, 'A': 1}
raster_time_index(gran, encoding='toc')  # -> {'B': 0, 'A': 1}  (row assignment stable, as claimed)
stored words   [11074147236267144192, 11074147113269437224]
ascending?     False
decoded starts ['2025-06-18T12:00:00.000000000' '2025-06-18T11:58:57.990118400']
argsort        [1, 0]

The two words are out of order by ~62 s, i.e. by the item's start_datetime lead, which nothing bounds. The PR body's separate claim ("group order is still the earliest item datetime under both encodings, so a leaf's row assignment can't drift") is correct and I verified it — this is only about the sort sentence in §8.1.

Why it matters beyond wording: #443's body sells np.sort order as a headline property, and the natural reader implementation of a time window on a "stored ascending" axis is a bisect, which here silently returns the wrong rows. The final sentence ("A reader MUST NOT assume the stored words are strictly increasing") is doing real work but reads as a tie/degeneracy caveat, not "the axis may be materially unsorted".

Two coherent resolutions, both fine by me:

  1. Spec-side: state the order key explicitly — words are stored in ascending order of the group's earliest item datetime, which is not the encoded start when a declared span precedes it; drop the "one start quantum" quantifier.
  2. Writer-side: order groups by span[k][0] under toc, making the axis self-sorted at the cost of a row assignment that differs from the legacy encoding (which the PR deliberately avoids).

tests/test_time_axis.py::TestRoundTrip::test_mixed_variants_sort_chronologically and test_spec_conformance.py::TestTemporalDeclaration::test_stored_words_match_the_golden both assert np.sort(words) == words on data with no spans, so neither catches this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Fixed the claim, not the ordering — resolution (1) — in 5805922c. Your counterexample is reproduced verbatim by the new test; the disagreement is unbounded and the old sentence's "one start quantum" was simply wrong.

Taking the writer-side option (2) would have bought a self-sorted axis at the cost of the cross-encoding row-assignment stability this PR built deliberately, and that property is the more load-bearing one (a leaf's slab rows must not move when the encoding changes). So the axis stays as-is and §8.1 now says what it actually does. The bullet is split in two, because the two facts were being conflated:

  • Row order is the acquisition-group order: timesteps are ordered by the group's earliest member observation time — the order the time axis has always had, the order the (time, cells) slabs were indexed by, and identical under both encodings, so a row assignment never drifts with the encoding.
  • Stored word order is not that key, and a reader MUST NOT assume the stored words ascend. Unsigned word order is order by the encoded start, which is the conservative envelope start — and an envelope may begin before its group's earliest member observation time, by an amount nothing in this section bounds. Where it does, the word leads the row key and the stored axis is materially out of order: np.sort(words) yields ascending envelope-start order, which is not guaranteed to equal row order. In particular a reader MUST NOT bisect the stored axis to resolve a time window; use the overlap predicate below, which is correct regardless of stored order.

The explicit bisect prohibition is there because you're right that it's the natural reader implementation and the failure is silent.

Tests. Both existing assertions are kept, and the divergence is now pinned in two places:

  • tests/test_raster_pipeline.py::TestTocTimeIndex::test_a_leading_span_puts_the_stored_words_out_of_row_order — your counterexample through the production function: dt-2's start_datetime (16:00:59) precedes dt-1's item datetime (16:02:20), an ~81 s lead. It asserts both halves at once — index == raster_time_index(granules)[0] == {"dt-1": 0, "dt-2": 1} (row assignment stable) and words[1] < words[0] / not np.array_equal(np.sort(words), words) (stored axis unsorted).
  • tests/test_time_axis.py::test_a_leading_envelope_start_breaks_stored_word_order — the same divergence at the encode layer.

test_mixed_variants_sort_chronologically is renamed test_mixed_variants_sort_by_envelope_start — its data ascends by envelope start, which is what unsigned word order actually is, so the test is fine once it stops claiming to be about acquisition order.

The conformance assertion at test_stored_words_match_the_golden stays, with the reason it holds spelled out rather than implied:

# These particular words ascend, but that is INCIDENTAL to this
# fixture: its three groups are days apart, so no envelope start can
# lead the row before it. §8.1 does NOT promise ascending stored
# words -- the span-lead counterexample is pinned by
# test_raster_pipeline.py::TestTocTimeIndex::
# test_a_leading_span_puts_the_stored_words_out_of_row_order.

(Worth noting the fixture does carry a span lead — dt-2's envelope starts at 15:06:38 vs dt-1's 15:06:40 — it just cannot reorder anything, because the groups are three days apart. Which is exactly why it never caught this.)

Also corrected the same overclaim at its source: raster_time_index's docstring said "the axis is ascending acquisition order under both encodings" and returned "both in ascending time order". It now says row order is the earliest item datetime, that this is not ascending stored words under toc, and points at the overlap predicate.

plain ascending sort of the stored words reproduces the stored order
except where two timesteps fall in one start quantum, where the grammar's
own tiebreak decides. A reader MUST NOT assume the stored words are
strictly increasing.
- Decoding to wall time yields **`(start, end)`** per timestep: for a
timestamp both bounds are its exact instant; for a range `end` is the
envelope's **exclusive** upper bound. A reader that must present one
instant per timestep SHOULD present `start`, and MUST NOT present a
midpoint as if it were the observation time — the midpoint of a
conservative envelope is not an observation.
- Temporal window selection is the grammar's overlap predicate applied to
the stored words directly: it over-reports by at most one quantum at a
window edge and **never under-reports**, so a selection is a conservative
superset of the timesteps whose real acquisition intersects the window.

**Composition.** Time axes compose only between **matching declarations** —
`zagg-toc/1` with `zagg-toc/1`, legacy with legacy (an absent key is legacy
for this rule too). A concatenation across encodings would produce an axis
whose values mean two different things, so a reader or writer joining two
stores' time axes MUST refuse a mismatch. This is a *join* rule only:
reading either store on its own is always legal.

**What this revision does not cover** (informative). Per-cell and
per-centroid temporal companions — the other shapes of #410 — are not
defined here; they will arrive as further `shape` values under this same
`spec` marker and this same word grammar. Nothing in §8 constrains the
`(time, cells)` band arrays themselves, which are unchanged.
25 changes: 25 additions & 0 deletions src/zagg/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,13 @@ def validate_config(config: PipelineConfig) -> None:
# validated before the kind branch, like credentials_provider above.
_validate_worker(config)

# The time coordinate's encoding (spec §8, issue #443) is checked on every
# pipeline kind, like the two blocks above: the value is only meaningful
# where a time coordinate exists, so declaring it elsewhere is a typo
# worth catching at submission rather than a knob that silently does
# nothing.
_validate_time_encoding(config)

ptype = get_pipeline_type(config)
if ptype != "spatial":
_validate_temporal_config(config)
Expand Down Expand Up @@ -757,6 +764,24 @@ def _validate_temporal_config(config: PipelineConfig) -> None:
_validate_collection_options(config)


def _validate_time_encoding(config: PipelineConfig) -> None:
"""Validate ``output.time_encoding`` (spec §8, issue #443).

The knob declares how the ``(time, cells)`` time coordinate is encoded —
legacy int64 microseconds (absent/default) or mortie toc words. It is
raster-only: the spatial and temporal pipelines write no time coordinate,
so a declaration there would be inert.
"""
from zagg.time_axis import DEFAULT_TIME_ENCODING, time_encoding

encoding = time_encoding(config)
if encoding != DEFAULT_TIME_ENCODING and (config.data_source or {}).get("reader") != "raster":
raise ValueError(
f"output.time_encoding: {encoding!r} applies to raster (time, cells) "
f"products only — this pipeline writes no time coordinate (spec §8)"
)


def _validate_raster_config(config: PipelineConfig) -> None:
"""Validate a raster (pull-NN) pipeline config (issue #218).

Expand Down
8 changes: 7 additions & 1 deletion src/zagg/configs/sentinel2_l2a.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# cell-ID joins are direct) takes the source pixel nearest its center --
# dense by construction, exact uint16 DNs. Reflectance = DN * 1e-4 - 0.1,
# recorded as CF scale_factor/add_offset attrs and never applied to the
# stored data. Timesteps are datatakes (time_key), not item datetimes:
# stored data. Timesteps are datatakes (time_key, toc-encoded), not item datetimes:
# adjacent MGRS tiles of one datatake are a single (time, cells) slab, with
# the 9,840 m tile overlap resolved by nearest-tile-center ownership.
#
Expand All @@ -32,6 +32,12 @@ data_source:
anonymous: true

output:
# Time axis as mortie toc words (spec §8, issue #443): a datatake is a
# ~seconds-long acquisition whose adjacent MGRS tiles are sensed seconds
# apart, so the honest coordinate value is a conservative RANGE -- which
# int64 microseconds cannot state and a toc word can. Output-defining: a
# store born on toc is a different product from a legacy-axis one.
time_encoding: toc

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

This flip makes docs/hive_layout.md state the wrong thing about the default raster leaf. That page's "Raster hive stores (issue #247)" section, line 908, describes every raster hive leaf unconditionally:

each carrying leaf-local time (int64 microseconds, CF attrs) and morton (packed u64 words) as the sole cell coordinate

After this line, the shipped S2 config — the only raster config zagg packages, and the production HEALPix raster path — writes that array as uint64 toc words with no CF attrs (time_axis_attrs("toc") returns {"temporal": {...}} and nothing else; TestTocTimeIndex::test_template_declares_and_round_trips asserts "units" not in tarr.attrs). So the narrative doc now contradicts the default for the one product it is describing.

One clause fixes it — e.g. "leaf-local time (int64 microseconds with CF attrs, or uint64 toc words carrying the §8 temporal declaration under output.time_encoding: toc)". §4 puts narrative markdown on the same footing as docstrings and the spec, and docs/specification.md was updated in this PR while this page was not.

Same class, lower stakes, in the wire-payload docstrings: deployment/aws/lambda_handler.py:83 ("times_us": [int, ...] (raster only, issue #264) -- the catalog-derived…) and src/zagg/runner.py:5060 ("times_us is the catalog-derived global time coordinate (int64 μs since the epoch), JSON-safe as a plain int list") both still declare the payload as int64 µs. The values are still JSON-safe plain ints so nothing breaks — [int(t) for t in times_us] on a uint64 array yields Python ints of arbitrary precision — but the sizing note two lines below (int64 μs stamps serialize at ~17 B each → ~350K-timestep headroom) is computed off the wrong width; a toc word is 20 decimal digits.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

All three fixed in 2aad8790.

docs/hive_layout.md:908 — the unconditional claim now covers both encodings, and names the config that flips it, since that's the part that made the page wrong:

each carrying leaf-local time (int64 microseconds with CF attrs by default, or uint64 mortie toc words carrying the spec §8 temporal declaration and no CF attrs under output.time_encoding: toc — which the shipped Sentinel-2 config sets, issue #443) and morton (packed u64 words) as the sole cell coordinate

deployment/aws/lambda_handler.py:83 — the payload docstring. Added the point that the key name is not a width claim, because that is what will mislead the next reader:

"times_us": [int, ...] (raster only, issue #264) -- the catalog-derived time coordinate, in whatever encoding "config" declares: int64 microseconds since the Unix epoch by default, or uint64 mortie toc words under output.time_encoding: toc (spec §8, issue #443). Plain ints either way; the worker re-derives the dtype from the config, so the key name is historical and NOT a width claim.

Verified that last clause rather than asserting it: emit_raster_template does times_us = np.asarray(times_us, dtype=time_axis_dtype(time_encoding(config))), so the wire really does carry untyped ints and the dtype really is re-derived worker-side.

src/zagg/runner.py:5060 — same correction on _invoke_raster_setup's docstring, plus the sizing note two lines below, which you're right was computed off the wrong width:

the widest stamp is a 20-digit uint64 toc word at ~21 B → ~285K-timestep headroom (int64 μs stamps are ~17 B / ~350K), both orders above any real catalog (the pinned NEON benchmark catalog is 85 items).

The conclusion survives the recomputation — 285K is still orders above the 85-item pinned catalog — but it is now the number for the encoding this PR makes default.

grid:
type: healpix
parent_order: 11
Expand Down
Loading
Loading