Skip to content

Raster time axis encodes as toc words (spec §8, S2 first) - #446

Merged
espg merged 11 commits into
mainfrom
claude/443-toc-time-axis
Aug 17, 2026
Merged

Raster time axis encodes as toc words (spec §8, S2 first)#446
espg merged 11 commits into
mainfrom
claude/443-toc-time-axis

Conversation

@espg

@espg espg commented Aug 17, 2026

Copy link
Copy Markdown
Member

Closes #443. Refs #410 (the temporal series this is the first implementation slot of), #218 (S2 ingest), espg/mortie#175 (the toc word grammar).

What this does

The raster family's (time, cells) time coordinate can now be mortie toc words — one uint64 per timestep that is either an exact nanosecond timestamp or an outward-rounded conservative range — instead of int64 microseconds. Sentinel-2 is flipped to it (src/zagg/configs/sentinel2_l2a.yaml), because a datatake is honestly a ~seconds-long acquisition whose adjacent MGRS tiles are sensed seconds apart, which datetime64/int64-µs cannot state and a toc range can.

The encoding is declared, not inferred: a spec-owned temporal attrs block on the time array, with absent ⇒ legacy so every store written before this PR reads exactly as before.

Approach

Declaration (spec §8, new). docs/specification.md gains ## 8. zagg-toc/1. The grammar mirrors §2.0's weights pattern (PR #431): a strict-checked spec marker, an absent-key default that keeps old stores conformant verbatim, a reader MUST-refuse on unknown values, and a composition-legality rule. The block is:

"temporal": {
  "spec": "zagg-toc/1",
  "shape": "coordinate",
  "grammar": "mortie-toc/1"
}

Three keys and no more, which is exactly the shape #410 ruled (correction comment: {shape, grammar}, no per-store epoch/quantization guards). An earlier revision of this branch also stamped epoch/timescale/quantum_start_ns/quantum_end_ns; the adversarial review caught the divergence and they are gone — they are properties of the cited grammar, so a per-store copy could only restate a constant while entering committed fixture bytes.

Both token spellings are espg's rulings (in-session, 2026-08-17), replacing the placeholders this branch carried — see the resolved question (5) below and the fixture-diff disclosure. shape: "coordinate" because the raster time array is the CF/xarray coordinate variable of the time dimension, and downstream consumers (xdggs, moczarr's DataTree model) live in that vocabulary. grammar: "mortie-toc/1" because a grammar revision in the ecosystem's {name}/{major} style (zagg-ragged/1, morton-hive/2) keeps store bytes still when documentation moves — the earlier "mortie/toc@0.9.6" welded a docs URL's release pin into every store's committed bytes.

§8's preamble is now the domain-neutral form (espg-ruled design). The section opens by defining a word-typed coordinate declaration — the {spec, shape, grammar} pattern for any array whose elements are packed words rather than self-describing scalars — with the shape vocabulary {"coordinate", "per-cell", "per-centroid"} defined once, domain-neutrally, and then instantiates it for the temporal domain (attrs key temporal, spec: "zagg-toc/1", grammar: "mortie-toc/1"). Two forward instantiations are stated as non-normative notes: #410's per-cell/per-centroid temporal companions (same spec, same grammar, further shape values), and the located (spatial) companion family declaring under the same pattern with grammar: "mortie-morton/1" in the #410 kernel PR. That note also draws the boundary explicitly: the store's primary morton axis stays declared by the morton-hive/{1,2} manifest grammar and the DGGS-convention dggs attrs — this pattern covers word-typed coordinate/companion arrays beyond that primary surface and never a second, competing declaration of it.

The word grammar itself is not restated: §8 cites mortie.toc at 0.9.6 + espg/mortie#175. That URL is now explicitly the documentation pointer in prose, not the stored token: it stays release-pinned because mortie's docs are mike-versioned and the unversioned path 404s, but re-pointing it moves no store byte. §8 still states plainly that it is not yet the same class of reference as §2.2's — §2.2 defers to mortie's frozen spec §1/§4, whereas mortie's frozen spec carries no toc section at all. espg/mortie#193 tracks adding one; when it lands the pointer swaps to it and the stored grammar token does not move.

One module owns both halves. New src/zagg/time_axis.py (~230 lines): declaration constants + temporal_declaration() strict-check, time_axis_dtype/time_axis_attrs for the writer, encode_time_axis, decode_time_axis, time_axis_overlaps, read_time_axis. It is a new module rather than more of src/zagg/grids/base.py (where #431 put WEIGHTS_ATTR) for two reasons: grids/base.py owns the aggregation path's array specs and the raster path deliberately bypasses it (processing/raster.py:827-833), and processing/raster.py is already 1,495 lines — the §4 cap says don't grow it. Flagged under "Questions for review" in case you'd rather the constants live beside weights.

Write path. raster_time_index(granules, *, encoding=...) (src/zagg/processing/raster.py) now also accumulates each acquisition group's envelope — earliest time_start (or item datetime) to latest time_end (or item datetime) across its members. Under toc a degenerate envelope encodes with time2toc (exact instant, never widened) and a real interval with span2toc (conservative range, never narrowed). Row order is still the group's earliest item datetime under both encodings, so a leaf's row assignment cannot drift with the encoding.

That is not the same as an ascending stored axis, and the review caught the PR overclaiming it. A word encodes the envelope start, which a declared start_datetime can push before an earlier row's key — by an amount nothing bounds (the review demonstrated ~62 s). So np.sort(words) gives ascending envelope-start order, which is not guaranteed to equal row order; §8.1 now says so and explicitly forbids bisecting the stored axis. Window selection was already correct — it runs the overlap predicate, not a bisect. Pinned by TestTocTimeIndex::test_a_leading_span_puts_the_stored_words_out_of_row_order. _raster_members takes dtype + attrs from the declaration, so both the flat template and the hive leaf template are covered by one change; the legacy branch is byte-identical to today (_TIME_ATTRS moved verbatim into LEGACY_TIME_ATTRS).

Read path. There was no reader for the raster time coordinate at all — readers/tdigest_tensor.py:297-301 refuses any 2-D array and client.py:493 refuses raster outright, so users reach for xr.open_zarr themselves (D13, docs/design/generalized_grids.md:425). So this adds the minimum: read_time_axis / decode_time_axis / time_axis_overlaps, re-exported from zagg.readers.

Decode returns (start, end), not a midpoint — justified two ways. (1) The existing reader surface returns numpy tuples (read_tensors yields (tensor, mask, (offset, gain), morton_index)), so a pair is the idiomatic shape and needs no new container. (2) The midpoint of a conservative envelope is not an observation — presenting it as the acquisition time would invent precision the encoder deliberately refused to claim. §8.1 says a reader that must show one instant SHOULD show start. Window selection runs toc_overlaps on the raw uint64 words with no decode, which is conservative in the honest direction (over-reports ≤ one quantum at an edge, never under-reports).

Semantic hash. output.time_encoding: toc is folded into semantic_core, keyed only when non-default — the identical treatment weights: "flux" gets, and for the identical reason ("the stored axis MEANS something else"). Because it is keyed only when non-default, every config written before this PR hashes byte-identically to today; tests/test_semantics.py::TestTimeEncodingHashing::test_default_encoding_hashes_as_absent pins that. This was the one place the change could have cascaded into a semantic-hash question, and the absent-key-default normalization dissolves it rather than deferring it — but it is called out under "Questions for review" since it is your call whether the raster axis encoding belongs in D19's core at all.

Phases

  • 1 — spec §8 + the §7 fixture text (93a25b5a)
  • 2 — write path, config knob + validation, semantic core, S2 template flip (b98dc94c)
  • 3 — read surface on zagg.readers (c66fb020)
  • 4 — the raster_toc/ conformance fixture + generator + conformance tests (f408907b)
  • merge + adversarial-review foldorigin/main merged in (847fe59b), then one commit per finding:
    • 03786bc2 — version-pin the toc grammar citation and drop the false §2.2 precedent
    • 5805922c — state the real time-axis order key and pin the span-lead divergence
    • 97c69fd0 — reshape the temporal declaration to the ruled {shape, grammar} (fixture regenerated)
    • 293ea6cb — derive the fixture acquisitions from RASTER_GRANULES
    • 2aad8790 — make the time-axis docs conditional on the declared encoding
  • espg token rulings (2026-08-17)b932fa11shape: "axis""coordinate", grammar: "mortie/toc@0.9.6""mortie-toc/1", and §8's preamble generalized to the domain-neutral word-typed coordinate declaration (fixture regenerated)

Merge note (847fe59b)

The branch was 38 commits behind main; origin/main (9538d244) is merged in rather than rebased. tests/test_spec_conformance.py auto-merged take-both — #438's re-pinned kitchen_sink literals and this branch's raster_toc entry live in the same FROZEN_COMBINED/FROZEN_ARRAYS dicts without overlapping lines — and no fixture needed regenerating for the merge: raster_toc carries no *_locations array, so #438's authalic change cannot reach it, and the full conformance suite passed on the committed bytes under mortie 0.9.8 before any fold landed. pyproject.toml now carries main's mortie>=0.9.8 floor.

The two test_content_hash.py failures this body previously listed as "pre-existing and unrelated" were fixed by #438 on main and now pass; "How it was tested" below is the post-merge, post-fold state.

Fixture diff — the blast radius, precisely

Per the #340 rule this is a wire-format change, so fixtures move in the same PR. The blast radius is exactly one new fixture; nothing committed before this PR changed by a single byte.

git show --stat f408907b is:

  • new tests/data/spec/raster_toc/ — 38 files: one raster hive leaf (1/1/2/1/3/11213.zarr) with time (3 toc words, 1 chunk), morton (4 chunks), red/scl (3, 16) bands (4 chunks each × 3 timesteps), coverage.moc, granules.json, plus morton_hive.json.
  • new tests/data/spec/raster_toc.expected.json
  • modified tools/generate_spec_fixtures.py (the raster_toc builder + docstring) and tests/test_spec_conformance.py (TestTemporalDeclaration + two frozen literals).
  • unchanged: minimal/, kitchen_sink/, flux/, column/, pyramid/ and all four of their *.expected.json — regenerated with --only raster_toc, so the "stale by design" manifests the generator docstring warns about were not refreshed. FROZEN_COMBINED / FROZEN_ARRAYS gained two entries and no existing literal moved. git status after generation listed only the two new paths.

Regenerated in the review fold (97c69fd0)

The declaration reshape changed stamped attrs, so --only raster_toc was re-run. The diff is attrs-only — three keys removed, one added — in two files:

  • tests/data/spec/raster_toc/1/1/2/1/3/11213.zarr/6/time/zarr.json — the temporal block: epoch, timescale, quantum_start_ns, quantum_end_ns removed; grammar: "mortie/toc@0.9.6" added.
  • tests/data/spec/raster_toc.expected.json — its time_attrs mirror, same four-out/one-in.

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

No chunk object changed and no content hash moved. time_words, time_bounds_ns, acquisitions, morton, bands and every content_hashes entry are byte-identical; FROZEN_COMBINED["raster_toc"] (42263e04…) and the FROZEN_ARRAYS entry are untouched literals, and TestTemporalDeclaration::test_frozen_digests_pin_the_recipe passes on the regenerated bytes. The §5 recipe digests array data, not array attrs, so the declaration lives in the committed bytes but not in the O11 hash. No other fixture was regenerated — minimal/, kitchen_sink/, flux/, column/, pyramid/ are still untouched by this PR.

Regenerated for the ruled tokens (b932fa11)

The two ruled token spellings are stamped attrs, so --only raster_toc was re-run into a temp dir and diffed against the branch's committed state. The whole diff is four lines in two files (diff -rq over the fixture tree plus a json.tool diff of raster_toc.expected.json):

  • tests/data/spec/raster_toc/1/1/2/1/3/11213.zarr/6/time/zarr.json"shape": "axis""coordinate", "grammar": "mortie/toc@0.9.6""mortie-toc/1".
  • tests/data/spec/raster_toc.expected.json — its time_attrs mirror, the same two lines.

The only other files diff -rq flagged are the two informative stamps §7 already declares non-conformance-asserted (.../11213.zarr/zarr.json's written_at, morton_hive.json's generated_at), so those two were left at their committed values and the token change was applied in place — the committed fixture is now byte-identical to the regenerated one apart from those stamps (verified: json.tool diff of both changed files against the regen output is empty).

No hashed literal moved. time_words, time_bounds_ns, acquisitions, morton, bands, every content_hashes entry, FROZEN_COMBINED["raster_toc"] (42263e04…) and FROZEN_ARRAYS[("raster_toc", "6/time")] (551f7be5…) are all untouched — the §5 recipe digests array data, not array attrs, so a declaration reword lives in the committed bytes but not in the O11 hash. TestTemporalDeclaration::test_frozen_digests_pin_the_recipe passes on the reworded bytes. No other fixture was regenerated; minimal/, kitchen_sink/, flux/, column/, pyramid/ remain untouched by this PR.

Separately, 293ea6cb replaced the hand-transcribed acquisitions literals with a derivation from RASTER_GRANULES; verified byte-identical by regenerating into a temp dir and diffing (empty except the two informative stamps), so the derived values reproduce the three literals exactly.

What the fixture pins

The new fixture is deliberate about what it pins: its three timesteps commit both word variants — two multi-member groups become RANGE words (one derived from member instants 7 s apart, one from a STAC start_datetime/end_datetime pair) and one single-member group stays an exact TIMESTAMP — so a reader that implements only one variant fails a §7 fixture. raster_toc.expected.json records the words as decimal strings, the (start, end) ns a conforming decode yields, and the real acquisition spans they must contain, which makes §8.1's conservative-containment claim assertable on committed bytes. The other four fixtures carry no temporal key anywhere, which is the committed absent-key ⇒ legacy pin.

It is written through the production raster hive seam (process_and_write_raster_hive — leaf template, per-timestep slab streaming, coverage sidecar, commit stamp, O11 hashing) with only the COG sampling faked, mirroring how build() fakes process_shard for the digest fixtures. A committed fixture must regenerate with no network and no GDAL, and the pull-NN arithmetic is tests/test_raster.py's to pin, not §7's.

How it was tested

Post-merge (847fe59b) and post-fold (through b932fa11), mortie 0.9.8 / zarr 3.3.0:

  • pytest -q4009 passed, 38 skipped, 1 failed. The single failure is environmental in the dev worktree and untouched by this PR: test_lambda_build.py::TestFunctionBuild::test_function_build_succeeds needs Docker. (TestLambdaImports::test_h5coro_hidefix_available, previously listed here, now passes — the local venv picked up the >=0.3.2 floor main raised in e6057012.)
  • pytest tests/test_time_axis.py tests/test_spec_conformance.py tests/test_raster_pipeline.py -q232 passed, the targeted gate for the token rulings.
  • The two test_content_hash.py failures previously listed here now pass (36 passed, 2 skipped), fixed by Re-pin benchmark shardmap fixtures under the authalic convention (mortie 0.9.8) #438 on main. pytest tests/test_spec_conformance.py -q114 passed.
  • ruff check src tests and ruff format --check src tests — clean apart from two pre-existing findings on files this PR does not touch (src/zagg/registry.py:64 N818, a markdown block in tests/data/benchmark/README.md), both of which reproduce on origin/main. Flagged, not fixed, per §4.
  • pre-commit run --all-files: mypy reports 146 pre-existing errors across 29 files, none in any file this PR adds and none at any line it edits; codespell and check-yaml failures are all pre-existing (CloudFormation !Ref-style tags, "GES"/"statics" in untouched modules). Repo-config ruff also reports the pre-existing N818 on registry.py:64, which the CI flag set excludes.

New coverage: tests/test_time_axis.py (declaration grammar and every strict-check including the uncited-grammar refusal and the informative-keys pass-through, span→word→range containment bounded by one quantum each end, degenerate-envelope-stays-exact, sort-by-envelope-start and the span-lead case that breaks stored word order, empty axis, pre-epoch and inverted-envelope refusals, window predicate, legacy parity); TestTocTimeIndex in test_raster_pipeline.py (group envelopes, the STAC start/end path, the row-order/word-order divergence on the production function, legacy values unmoved when a catalog gains span columns, flat + leaf templates, legacy store still reads); TestTimeAxisReader in test_readers.py; TestTimeEncoding in test_config.py; TestTimeEncodingHashing in test_semantics.py; TestTemporalDeclaration in test_spec_conformance.py (11 tests over committed bytes).

Questions for review

  1. Semantic-hash placement. output.time_encoding is now in semantic_core, keyed only when non-default so no existing hash moves (test pins this). The alternative reading is that it belongs under D19's "packaging" — it changes stored bytes but not values in any physical sense. I took the weights: "flux" precedent as decisive; say the word if you'd rather it stay out.
  2. Where the §8 constants live. src/zagg/time_axis.py rather than src/zagg/grids/base.py beside WEIGHTS_ATTR, for the module-cap and path-ownership reasons above. Happy to move them if you want one home for spec-owned attrs keys.
  3. One line in deployment/aws/lambda_handler.py. The flat-path setup branch cast the wire payload with np.asarray(event["times_us"], dtype=np.int64), which silently overflows on a uint64 toc word. It now casts with time_axis_dtype(time_encoding(config)). That file is worker application code (routinely touched by Unified second-pass rollup sweep: stats, shardmap sub-maps, MOC, overviews #300/Run-record write violates D8 orchestrator-no-write: route via worker invoke like the root MOC #313/zagg.client v2 transport: Event invoke + status-object future resolver #327/Sweep telemetry: per-family duration_s in run_sweep summary, handler response, and a persisted record #353), not an infra/deploy script, but it is under deployment/aws/ so I am flagging it explicitly. The hive path — the production default for HEALPix raster — needed no handler change at all: hive events carry no times_us and the worker derives its leaf-local axis itself.
  4. The benchmark config was left alone. tests/data/benchmark/configs/s2_neon_o9.yaml still says it is "identical to the shipped sentinel2_l2a.yaml except parent_order: 9", which is now one knob short of true. I did not flip it because doing so moves the raster release leg's product identity mid-series. Tell me which you want and I will make the config and its comment agree.
  5. shape: "axis" naming, and the grammar token's spelling. RESOLVED — espg ruled both in session on 2026-08-17, folded in b932fa11: shape: "coordinate" (the raster time array is the CF/xarray coordinate variable of the time dimension, which is the vocabulary xdggs and moczarr's DataTree model already speak) and grammar: "mortie-toc/1" (a grammar revision token in the {name}/{major} style of zagg-ragged/1 / morton-hive/2, so store bytes never move when documentation moves; the mike-versioned API URL lives in §8's prose 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). The same ruling generalized §8's preamble to the domain-neutral word-typed coordinate declaration described above. Fixture blast radius disclosed under "Regenerated for the ruled tokens" — four lines, no hashed literal moved.
  6. No zagg.client surface. client.py refuses raster outright, so the time-axis reader lives on zagg.readers only. Not obviously wrong, but worth a sanity check that this is the surface you want raster consumers reaching for.

Not done here (deliberate)

@espg espg added the implement label Aug 17, 2026
Comment thread docs/specification.md Outdated
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.

Comment thread docs/specification.md Outdated
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.

Comment thread docs/specification.md Outdated
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.

Comment thread tools/generate_spec_fixtures.py Outdated
],
# The REAL acquisition envelopes the stored words must contain — the
# §8 conservative-containment claim, pinned on committed bytes.
"acquisitions": [

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 acquisitions block — the thing that makes §8.1's containment claim assertable — is hand-transcribed and not derived from RASTER_GRANULES, so it cannot detect the drift it exists to prevent. Everything else in expected is read back from the written store (words, attrs, lo/hi, morton, bands, hashes). These three literals are typed in beside it.

They are correct today — I checked each against RASTER_GRANULES above: dt-1 = min/max of its two member datetimes (15:06:40 / 15:06:47), dt-2 = its time_start/time_end (15:06:38 / 15:06:49), dt-3 = its single instant. The failure mode is later: edit a granule's datetime or add a time_start, regenerate, and time_words/time_bounds_ns move while acquisitions does not. TestTemporalDeclaration::test_bounds_conservatively_contain_the_real_acquisitions then asserts containment of an interval that is no longer the fixture's real acquisition — and because outward rounding gives it seconds of slack on each side, it will very likely still pass. The one assertion in §7 that pins honesty rather than bytes is the one with no tie back to its inputs.

Cheap fix, same shape as the rest of the builder — derive it from the same grouping the encoder used:

groups: dict = {}
for g in RASTER_GRANULES:
    lo = g.get("time_start", g["datetime"])
    hi = g.get("time_end", g["datetime"])
    k = g["time_key"]
    groups[k] = (min(groups[k][0], lo), max(groups[k][1], hi)) if k in groups else (lo, hi)
"acquisitions": [
    {"key": k, "start": lo[:-6], "end": hi[:-6]}
    for k, (lo, hi) in sorted(groups.items(), key=lambda kv: kv[1][0])
],

I confirmed this regenerates byte-identically otherwise: --only raster_toc into a temp dir reproduces every chunk object and raster_toc.expected.json exactly, with only the two informative written_at/generated_at stamps differing (which §7 already declares non-conformance-asserted).

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

Taken as written — 293ea6cb. Your diagnosis is exact: it was the one assertion in §7 pinning honesty rather than bytes, and it had no tie back to its inputs.

Lifted to a named helper next to RASTER_GRANULES rather than inlined, since it needs the row-order key too (the group's earliest item datetime, which is not the envelope start — see the span-lead thread above, so sorted(..., key=envelope_start) would have produced the wrong order for a fixture with a leading span):

def _raster_acquisitions() -> list[dict]:
    span: dict[str, tuple[str, str, str]] = {}
    for g in RASTER_GRANULES:
        key = g["time_key"]
        lo, hi = g.get("time_start", g["datetime"]), g.get("time_end", g["datetime"])
        if key in span:
            was_lo, was_hi, was_dt = span[key]
            span[key] = (min(was_lo, lo), max(was_hi, hi), min(was_dt, g["datetime"]))
        else:
            span[key] = (lo, hi, g["datetime"])
    return [
        {"key": key, "start": lo[:-6], "end": hi[:-6]}
        for key, (lo, hi, _dt) in sorted(span.items(), key=lambda kv: (kv[1][2], kv[0]))
    ]

with the reason at the callsite so it does not get re-transcribed later:

DERIVED from RASTER_GRANULES, never transcribed: a hand-typed copy would keep passing after an input edit moved the words, which is the one drift this block exists to catch.

Byte-identity confirmed independently of your run, and after the declaration reshape in 97c69fd0: --only raster_toc --out <tmp> against the committed tree gives an empty diff -r except the two informative written_at/generated_at stamps, and raster_toc.expected.json compares equal key-for-key — acquisitions included, so the derived values reproduce the three literals exactly.

# 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.

# The §8 temporal fixture (issue #443) — asserted by
# TestTemporalDeclaration (raster_toc is not in FIXTURES: it carries no
# ragged array at all, so nothing the leaf-shaped suite asserts applies).
"raster_toc": "42263e046ecf4d71de8460063b38e6f15522e245cdc2182ce3a0acaf35db7e4e",

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 branch is behind main, and two of the three failures the PR body calls "pre-existing and unrelated" are already fixed there — so the tested-state claim is stale and this file will conflict on rebase.

$ git merge-base HEAD origin/main
ed8a31ac...                       # 20+ commits behind

main has since landed #438: 30866c7d re-pin the kitchen_sink spec fixture + content hashes under authalic and 8855bc32 bump the mortie floor to 0.9.8 for the authalic convention. Running the two named tests on each side:

# this branch
FAILED tests/test_content_hash.py::TestWorkerWiring::test_matches_pinned_fixture_hashes
FAILED tests/test_content_hash.py::TestStreamingLeafStaging::test_unsharded_leaf_reproduces_pinned_hashes_from_staged_values

# origin/main (9538d244), same interpreter, same mortie 0.9.8
2 passed, 36 deselected

So they are not #441's open pinned-hash pair any more — post-rebase the PR should be green on both, and the "How it was tested" section should say so rather than carry them as known-failing.

Two concrete consequences beyond the bookkeeping:

  • pyproject.toml diverges: this branch still says mortie>=0.9.6, main says mortie>=0.9.8. That is the floor the §8 grammar citation should track (see my comment on docs/specification.md, where I suggested pinning the doc URL to 0.9.6 — after rebase the right pin is 0.9.8; both resolve, 0.9.8 matches the post-rebase floor).
  • This file conflicts: Re-pin benchmark shardmap fixtures under the authalic convention (mortie 0.9.8) #438 re-pinned kitchen_sink's hashes in FROZEN_COMBINED/FROZEN_ARRAYS, the same literals this commit adds an entry to. A textual conflict only — resolve by keeping main's kitchen_sink values and this PR's raster_toc entry.

I checked the raster_toc fixture itself is not affected by the authalic change and needs no regeneration: #438's fixture diff touches only *_locations chunk bytes (lat/lon → point words) and their derived hashes, and raster_toc carries no locations array — its morton comes from grid.children(shard_key), pure word arithmetic with no latitude in it. I re-ran generate_spec_fixtures.py --only raster_toc under mortie 0.9.8 and got byte-identical chunks and expected.json.

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

Merged before folding anything else — 847fe59b, origin/main (9538d244) into the branch. Your read was right on every point.

The conflict did not materialize. Git auto-merged tests/test_spec_conformance.py take-both: #438's re-pinned kitchen_sink literals and this branch's raster_toc entry sit in the same FROZEN_COMBINED/FROZEN_ARRAYS dicts without overlapping lines. I checked the merged file rather than trusting the clean exit — kitchen_sink carries main's 7ee8ad92… under the authalic comment, raster_toc carries 42263e04…, and flux is untouched.

Post-merge evidence, on the same interpreter (mortie 0.9.8, zarr 3.3.0):

$ pytest tests/test_content_hash.py -q
36 passed, 2 skipped

$ pytest tests/test_spec_conformance.py -q
114 passed

So the two tests the PR body carried as "pre-existing and unrelated" —
TestWorkerWiring::test_matches_pinned_fixture_hashes and
TestStreamingLeafStaging::test_unsharded_leaf_reproduces_pinned_hashes_from_staged_values — now pass, exactly as you predicted. The "How it was tested" section is rewritten accordingly; they are no longer listed as known-failing.

No fixture regeneration was needed for the merge, confirming your analysis: raster_toc carries no *_locations array, its morton comes from grid.children(shard_key) (pure word arithmetic, no latitude), and the full conformance suite passed on the committed bytes under 0.9.8 before I touched anything. The regeneration that did land later (97c69fd0) is from the declaration reshape, not from authalic.

pyproject.toml now carries main's mortie>=0.9.8 floor. On the doc URL: I kept the citation at 0.9.6 rather than moving it to 0.9.8 — since toc.py/toc.rs are byte-identical across the two, the earliest release carrying the words is the more durable citation, and it stays valid the next time the floor moves. Reasoning is written into §8 itself; full detail on the citation thread.

Full suite after the merge and all five folds: 4008 passed, 38 skipped, 2 failed — both failures environmental in this worktree, neither touched by this PR:

  • TestLambdaImports::test_h5coro_hidefix_available — the local venv still has h5coro-hidefix 0.3.1, below the >=0.3.2 floor main raised in e6057012; the test asserts io_stats in SidecarIndex.read_group's signature, which 0.3.1 lacks. A local-env staleness the merge introduced, not a code failure.
  • TestFunctionBuild::test_function_build_succeeds — needs Docker.

ruff check src tests and ruff format --check src tests are clean apart from two pre-existing findings on files this PR does not touch (src/zagg/registry.py N818, tests/data/benchmark/README.md formatting) — both reproduce on origin/main, so flagged rather than fixed.

@espg
espg marked this pull request as ready for review August 17, 2026 02:11
@espg espg added the waiting label Aug 17, 2026
@espg
espg merged commit 23cbc21 into main Aug 17, 2026
8 checks passed
@espg
espg deleted the claude/443-toc-time-axis branch August 17, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Raster time axis encodes as toc words (instant|range) — S2 first

1 participant