Skip to content

temporal + located companion channels through the kernel and the pyramid (issue #410) - #463

Merged
espg merged 37 commits into
mainfrom
claude/410-temporal-kernels
Aug 17, 2026
Merged

temporal + located companion channels through the kernel and the pyramid (issue #410)#463
espg merged 37 commits into
mainfrom
claude/410-temporal-kernels

Conversation

@espg

@espg espg commented Aug 17, 2026

Copy link
Copy Markdown
Member

Refs #410 (the issue stays open for the fleet rebuild it gates).

The kernel + pipeline half of the temporal/located companion work. PR #456 landed the spec half — docs/specification.md §8 (zagg-toc/1) and §9 (zagg-located/1) plus the §7 conformance fixture at tests/data/spec/temporal/ — and deliberately landed the declaration surface ahead of any producer, with time_axis.TOC_PRODUCING_FUNCTIONS empty and config._validate_temporal_producer refusing every temporal: outright. This PR builds the producer and lifts that gate.

Implemented against the merged spec text, which post-dates parts of the consolidated plan — where they differ the spec wins.

Approach

One companion mechanism, two channels. Post-#279 the vectorized t-digest kernel already threads a per-centroid uint64 channel through the merge: _compress returns starts (the partition map from inputs to output centroids) and _centroid_ancestors reduces the located morton words over it. temporal= becomes the second channel through the same seam — _centroid_envelopes, reducing per-observation toc words over the same starts via mortie's segmented tocs_reduce. starts is already the arrow-offsets layout that call takes, so a whole cell's partition crosses into Rust once, with no per-centroid Python loop (the located channel still needs one for its multi-member centroids).

The temporal channel's law is stronger than the located one's, and the code says so. The digest payload is order-dependent (§2.3) and the located ancestor reduction is exact only given a centroid partition; the toc join is a semilattice, so a cell-level reduction is bit-identical under any fold tree (§8.2). That is what licenses §8.4's per-centroidper-cell coarsening at overview levels, and it is pinned by test_cell_envelope_is_fold_tree_independent.

Phases

  • 1. Kerneltemporal= beside locations= in build_tdigest / build_tdigest_where / build_tdigest_pairwise / merge_tdigests / merge_tdigests_kway; _centroid_envelopes via mortie.tocs_reduce; the quaternary tie key; zagg.stats.toc (the per-cell reducer + its segmented sibling); mortie>=0.9.9.
  • 2. Ingestoutput.time_source (the per-observation clock), time_axis.observation_words (the encode), the derived toc_word column, aggregate.py wiring for both companion shapes, the TOC_PRODUCING_FUNCTIONS gate lift, and named refusals on the two streaming paths. Pinned against tests/data/spec/temporal/ by a production-parity test.
  • 3. Located fields fold through the pyramid (ruling 4) — semantics.py reclassification, the channel threaded through every fold site (sweep_overview.fold_digests / _fold_node / _cascade_node / _fold_child, sweep_stage._gather_slabs / _merge_slabs), the overview template, and the retrofit gate. Fixture-neutral — verified, see below.
  • 5. P0 evidence — measured on real ATL03 repeat-track data; posted in full here. toc costs +2.0–2.1 B/centroid stored, +49.6–49.7% on a located digest field, stable across two independent bands.
  • 4. Temporal through the pyramid + template wiring — the fork was ruled option (c) (espg, 2026-08-17, amending ruling 3): companions are per-centroid at every level, symmetric with located. Fold plumbing generalized to N channels, both shipped templates wired, temporal/ fixture regenerated.

Phase 3 detail — ruling 4, and why it was a bug

semantics.field_composability classified a located ragged field none, and pyramid.py puts none fields in the excluded list, which docs/specification.md makes normative: "exact and approximate fields appear, none fields are absent." So declaring location: on a digest field removed that digest from every overview level — the hole diagnosed on the issue thread ("the location companion is a leaf-and-spill artifact only"), and what ruling 4 calls "the bug, not the design".

The fold law already existed — merge_tdigests_kway(..., locations=) has carried the channel since #279. What was missing was the wiring, at five sites:

site what it does now
fold_digests(..., locations=) the one k-way call that produces both outputs; returns a (payload, words) pair
_fold_node reads each leaf's {field}_locations inside the payload's guarded block, accumulates index-parallel with the digests, folds the pair per cell
_cascade_node / _fold_child the fold-of-folds path reads an overview's own sibling and re-merges it; children assign disjoint spans
sweep_stage._gather_slabs / _merge_slabs the staged sweep — gathers assign gen-1 bytes untouched; merges accumulate both channels per open cell and close them together
_overview_config / _field_drift the template emits the sibling; the retrofit gate refuses a declaration whose store has no sibling, wrong binding, or wrong element dtype

The pair is never folded in two passes. Spec §9.1/§2.3 make the words exact only given the centroid partition the merge produced, so a payload folded without its channel — or with it — would describe different partitions. Every site therefore reads both or neither: a leaf carrying the payload but no sibling is skipped loudly (failed += 1), never folded with the channel silently dropped, which test_a_leaf_without_the_sibling_is_skipped_loudly pins.

Heterogeneous orders are the point, not an accident. §9.1: "A fold coarsens only as far as its contributors force, so one overview array's words routinely carry different orders." An unmerged centroid keeps its order-29 point word beside a merged centroid's coarse area word.

Nothing changed for an unlocated field. The location key is recorded keyed-only-when-set, and test_unlocated_fields_are_byte_identical asserts an unlocated overview has no sibling and no locations binding.

Fixture impact: none, and that is checked rather than claimed

git status tests/data/ is empty on the phase-3 commit. The reason is specific: the only committed fixture field that is both located and folds under _TDIGEST_FUNCTIONS is temporal/h_tdigest, whose temporal: declaration still forces none; kitchen_sink's located strata fields use build_tdigest_where, which has no _TDIGEST_FUNCTIONS law. Reclassifying temporal is what moves committed bytes, and that is exactly the blocked half.

Module size

sweep_overview.py is 2,023 lines after this phase (1,908 before, on main). CLAUDE.md §4's 1,200-line raise trigger was crossed on that file by prior merged work; this diff is +115 lines of threading inside existing functions rather than new surface, so it is not the change that should trigger the split — flagging it rather than acting on it.

Phase 3 coverage

tests/test_sweep_overview.py::TestLocatedOverviewFold — six cases: the leaf fold writes the sibling row-aligned and validate_morton-clean; every overview word contains its contributors (§9.1, checked against the real member words per centroid via the digest's own weights, with a kind-aware containment helper — an order-29 point word folded with itself yields its cell's area word, so the naive comparison would be wrong); the sibling carries the §9 declaration and the payload binds it by name; the cascade folds the sibling too; a leaf missing the sibling is skipped loudly; and an unlocated field is byte-identical. Plus the flipped TestComposabilityClasses cases (located → approximate, temporal still none) and the manifest-entry test.

Phase 2 detail — where the nanoseconds come from

This is the phase that answers open question Q4 on the issue ("Where the per-observation nanoseconds come from"), and it answers it as that comment's option (b): a declaration of its own, requiring a continuous scale and rejecting scale: utc.

output.time_source — one clock per store.

output:
  time_source:
    field: delta_time                 # a base-rate data_source.variables column
    epoch: "2018-01-01T00:00:00"      # the column's zero, as a UTC instant
    scale: gps                        # continuous only — gps | tai
    units: seconds

Four decisions in it, each with its reason:

  1. Store-level, not per-field. The thread's own risk note — "Two time conventions in one store … an observation routed into window W whose toc start quantum reads as outside W. Cheap to prevent by construction (derive both from one conversion), expensive to discover later" — is prevented by construction: an absent block falls back to output.windowing whenever that block already carries a continuous-scale clock, so a windowed store has exactly one declaration feeding both window routing and toc ingest.
  2. scale: utc is refused by name. §8.3 requires an instant "exact to the nanosecond"; a nominal-UTC offset column is only good to the leap seconds elapsed since its epoch (windows.py's documented ≤ 1 s tolerance) — a full quantum wider than the range variant's ~2.15 s start grid is meant to imply. Window routing tolerates that; a word claiming nanosecond exactness cannot. A utc windowing block therefore does not serve as the fallback either.
  3. The conversion is single-sourced. observation_words takes its pre-2017 scale-vs-UTC correction from windows.utc_to_offset rather than recomputing it, so the two sides cannot drift: test_agrees_with_the_window_router_at_a_boundary decodes a routed boundary instant's word back to the very instant the router converted.
  4. The words are always timestamps, never ranges. zagg's readers deliver per-observation instants and §8.3 forbids widening one. Range ingest stays legal under the spec (§8.3's "range ingest is legal, not emitted"), just unreachable from any shipped reader.

One conversion point, both shapes. output.time_source materializes a derived per-observation column, toc_word — the toc analogue of the HEALPix leaf_id morton column, and reserved the same way (a config may read it, never declare it). Both companion shapes consume it:

  • temporal: per-centroidaggregate.py passes it as the reducer's temporal= channel, exactly as it passes leaf_id as locations=;
  • temporal: per-cell — the field declares source: toc_word and function: zagg.stats.toc.cell_envelope, so the config reads honestly (the field is derived from that column) with no magic name.

It is derived in calculate_cell_statistics rather than in the read path deliberately: that is the single funnel every route into the aggregation passes through (pooled, spill read-back, chunk-precompute), and per-cell derivation makes the total encode work exactly one pass over the shard's rows.

Gate lift. TOC_PRODUCING_FUNCTIONS is populated, and split into halves that partition by shape — TOC_PER_CELL_FUNCTIONS (whole-cell reducers) and the rest (digest kernels carrying the channel). A crossed declaration is therefore named as such rather than validating and writing a wrong-shaped array:

Variable 'observed': function 'zagg.stats.tdigest.build_tdigest' produces the 'per-centroid' temporal shape, not 'per-cell' — a whole-cell reducer cannot fill a per-centroid sibling, and a digest kernel's channel is not a dense per-cell array (spec §8.2/§8.3)

Two named refusals rather than two silent gaps. temporal: is refused under both aggregation.streaming modes, mirroring how validate_streaming already refuses a located field under mode: merge:

  • mode: merge — the running merged state carries no companion channel at all, so the §8.3 sibling would be stamped and left empty;
  • mode: spill — the words themselves fold exactly (the §8.2 join is associative, commutative and idempotent), but the block close's per-field channel state is located-only, so a temporal field would emit a companion missing every block past the first. That is a §8.3 row-alignment break, not an approximation, so it refuses and names the pooled path. Threading the channel through the block close is follow-on work, not a silent hole.

The ragged_locationsragged_channels contract. The aggregation stage's 5-tuple keeps its arity; its fourth element becomes {field: {channel: [per-cell words]}} instead of a locations-only mapping — the "generalize the channel first" shape from alternative (B) on the thread, taken as far as this PR needs it. write._channel_entry trims trailing absent channels, so a pre-#410 config still produces the historical 2-tuple sink entry and a located-only config the historical 3-tuple — byte-identical, not merely equivalent.

Fixture parity (CLAUDE.md §4)

tests/data/spec/temporal/ was committed in #456 with its words computed in the generator and handed to the production write path, because no reducer produced them yet. Rather than regenerate anything, this PR pins the production path to those committed bytes:

tests/test_spec_conformance.py::TestTemporalCompanions::test_the_production_kernel_reproduces_the_committed_words imports tools/generate_spec_fixtures.py (so the clock, the RNG and the cell plan cannot drift from the fixture they check), drives the generator's own inputs through the real reducers — build_tdigest(..., locations=, temporal=) and zagg.stats.toc.cell_envelope, with the words coming from observation_words — and asserts the digest, the located words, the per-centroid words and the per-cell word for all four populated cells. All four match, including the 1-observation cell that commits the exact-timestamp variant.

No committed fixture byte has moved in phases 1–2.

Testing

pytest -v green locally except two pre-existing failures unrelated to this diff, both flagged rather than fixed (CLAUDE.md §4): test_lambda_build.py::TestFunctionBuild::test_function_build_succeeds (environmental — needs the container build toolchain) and test_client_transport.py::TestStatusPoller::test_invoke_fault_burns_an_attempt_and_retries (wall-clock poller assertion). Note also that ruff format --check src tests reports tests/data/benchmark/README.md as unformatted on main — pre-existing, untouched here.

Phase-2 coverage: TestTimeSource (15 cases: the resolved shape, the windowing fallback and its utc non-fallback, every refusal, and the toc_word reservation from all three directions), TestObservationWords (12: the timestamp-never-range MUST, round-trip to the declared instants, units: days, the epoch floor and the ~2142 ceiling, non-finite refusal, and the window-router boundary agreement), TestTemporalCompanionProduction (16: both shapes out of one conversion, per-centroid containment, empty-cell channel arity, a temporal-only field, the words tracking the declared clock rather than the field source, _aggregate_chunk_cells' channel collection, _channel_entry arities), the rewritten TestTemporalShapeDeclaration (the lifted gate, the shape↔reducer partition, the clock requirement, the temporal kwarg reservation), and the three streaming/spill refusals.

Phase 1 detail

src/zagg/stats/tdigest.py

def _centroid_envelopes(temporal, starts, n):
    offsets = np.empty(len(starts) + 1, dtype=np.int64)
    offsets[:-1] = starts
    offsets[-1] = n
    return np.asarray(tocs_reduce(temporal, offsets), dtype=np.uint64)
  • Return shape: a declared channel adds an element to the returned tuple, in the fixed order (digest, locations, temporal). Either channel may be declared alone; the digest bytes are identical whichever are.
  • Tie keys (merge_tdigests_kway): lexsort gains one key per declared channel — location tertiary, toc quaternary — so a permutation of the inputs returns the same companion vectors, not just the same digest. lexsort takes keys least-significant-first, hence the reversal in the call.
  • The reserved 0 word is refused on input. §8.2 reserves 0 as the unobserved-cell marker and no encoder produces it, so a zero reaching a fold is a leaked fill value — and the join would happily absorb it and return an envelope reaching back to the grammar's 1850 epoch. This is the toc analogue of the validation the located channel gets for free from common_ancestor (which raises on a zero word).
  • zagg.stats.toc (new, 100 lines): cell_envelope is the temporal: per-cell reducer (§8.2 — one cell's observations in, one word out, raising rather than inventing an identity for an empty cell), and cell_envelopes its segmented sibling, which is §8.4's licensed shape-coarsening reduction and what phase 3's overview fold will call.

Dependency floor: mortie>=0.9.8>=0.9.9. tocs_reduce (espg/mortie#177) is released in 0.9.9. deployment/aws/build_layer.sh derives MORTIE_SPEC from this block per issue #322, so pyproject.toml is the only edit — nothing under deployment/ is touched.

Phase 1 coverage

  • tests/test_tdigest.py::TestTemporalChannel — the digest is byte-identical with and without the channel; singleton centroids round-trip exact nanosecond timestamps (toc_is_range is false across the whole vector, the §8.3 never-widen-an-instant MUST); merged centroids' envelopes contain their real members, asserted against the member instants rather than assumed — with a range's end treated as exclusive and a timestamp's two bounds as its instant (§8.1); NaN values drop their words; the reserved 0 is refused; both channels together return in the documented order and each is byte-identical to declaring it alone.
  • tests/test_tdigest.py::TestTemporalMergeLaws — permutation independence of the k-way fold over the channel; the two channels are independent of each other; fold-tree independence at cell level; and test_per_cell_envelope_is_the_envelope_of_the_per_centroid_words, which pins §8.3's closing clause directly (an overview's per-cell word IS the envelope of the per-centroid words beneath it, and equally of the raw observations).
  • tests/test_stats_toc.py — the per-cell reducer: one observation and many observations sharing one instant both stay timestamps (the GEDI shot-pooling case, ruling 2), spread observations become a conservative range, order independence, empty raises, reserved 0 refused, and it resolves through config.resolve_function.
  • tests/conftest.py gains toc_words, the temporal sibling of point_words, on the same clock as the §7 fixture generator.

Phase 4 detail — the ruled shape, and what it simplified

Ruled option (c): companions are per-centroid at every level, symmetric with located. The consequence worth stating is that this made the code smaller than the alternative, not larger — there is no second shape to produce, no per-cell overview array, and no binding grammar to invent.

The plumbing was generalized rather than duplicated. Phase 3 had threaded locations through five fold sites with a bespoke pair at each. Adding a second channel by copy would have doubled that. Instead fold_digests(..., locations=) became fold_digests(..., channels={kernel kwarg: [per-digest vectors]}), driven by one table:

COMPANION_CHANNELS = (
    ("location", "locations", ragged_locations_name),
    ("temporal", "temporal", ragged_times_name),
)

field_companions(name, meta) returns (kwarg, sibling) per declared channel in that fixed order — the kernel's own tuple order — so every site zips the merge's extra returns onto siblings positionally, and the order lives in exactly one place. check_located_match became check_companion_match(attrs, field, kwarg), whose temporal arm additionally pins the shape to per-centroid: a per-cell block reaching a per-centroid fold would decode one word per cell as a vector and produce envelopes whose containment claim is false, and that is a store this fold has no law for rather than an arithmetic error to discover later.

A temporal: per-cell dense field stays class none — the one sub-decision I made rather than read off the ruling, flagged as such in the ruling reply. Its fold law is the grammar's join over a cell group, not its own reducer, so classifying it by function would fold e.g. nanmax over toc words. The §8.2 shape still works at a leaf (that is GEDI's observed-style companion, and the fixture still commits it); only its pyramid behavior is left unwired, which no shipped config needs since GEDI declares pyramid: false.

GEDI's companion keeps ruling 2's honesty property, expressed per centroid. build_waveform_digest gained the channel: the clip mask and the value co-sort carry the words along (temporal[keep][order]) before reducing over the partition _compress returned — never reindexed afterwards, which would misalign it. All of a shot's samples share its instant, so a single-shot cell's centroids are exact timestamps and only genuinely pooled cells produce ranges. tests/test_read_vlen.py pins exactly that: toc_is_range false across the vector, one distinct word, decoding to 2018-01-01T00:00:01 for delta_time[0] == 1.0.

One validator widened. _validate_time_source had checked data_source.variables alone, which refused GEDI's shot-rate clock. Base-rate means one value per observation after the read, and a broadcast level variable qualifies — so the accepted set is now the same one every other column reference validates against (variables + _segment_variable_names). This is the narrow, real form of the ds_vars question the phase-2 review raised.

Adversarial review + fold (CLAUDE.md §2)

Three review passes, one per phase, each a fresh-context subagent posting inline findings, each folded by a separate subagent — 23 findings, 22 folded, 1 declined, every thread replied to. The three that changed behavior rather than prose:

finding outcome
blocking observation_words double-counted the leap offsetmortie.from_datetime64 is already leap-aware, so base carries the scale correction and subtracting windows.utc_to_offset on top made a pre-2017-epoch word 18 s (gps) / 37 s (tai) wrong, and 18 s away from windows.offset_to_utc shift dropped entirely (c602d1f8); the pre-2017 branch now has a test that fails without it (e6e6221f). Shipped 2018-epoch configs were unaffected — shift_s was 0 there — so no committed byte moved
blocking _overview_config's new location key made the §4.6 leaf column write a bound but empty location sibling column.leaf_slabs / fold_column now carry the pair (3fb8a21c)
blocking sweep_stage._gather_slabs silently wrote a payload without its channel, where _merge_slabs raised on the same condition both paths now skip the contributor loudly and count it unreadable, leaving both halves at fill (0e2ccd73) — the stage sweep is soft-barrier everywhere else, and the raise was the one thing that could take a whole level down on the expected case of a column predating a location: addition

Also folded: np.rint(offsets * 1e9) was up to 16 ns off the nearest nanosecond at ICESat-2 magnitudes, against §8.3's exact-to-the-nanosecond MUST — replaced with a floor/frac split combined in int64, measured at 0 ns against Fraction arithmetic (480ee86b); the two clock declarations are now cross-checked so a store cannot carry two (f666ec7c); the derived toc_word column no longer validates as a source on stores that never materialize it (ea9d3297); fold_digests gained a row-alignment check on its single-contributor arm — the common path, and the one that made the two blocking bugs silent (667654c3); check_located_match now gates the fold at both sites (aa195cf9); and the docstring overclaim that per-centroid words are "bit-identical under any fold tree" was corrected to the cell level, which is the only place §8.3 supports it (e1703a50).

Phase 4's round

Five findings, all five folded, every thread replied to. The one that mattered was latent rather than live, and is the class this process exists for:

  • fold_digests returned its channel slots in two different orders depending on which arm ran (db4ebb17). The merge arm delegates ordering to merge_tdigests_kway, which fixes it by its own literal table; the empty and single-contributor arms iterated the caller's dict. So channels={"temporal": …, "locations": …} would have put toc words into {field}_locations — on the majority path at the finest overview level — and because mortie.validate_morton accepts toc words while mortie.toc_is_range accepts morton words, the swap survives every downstream check and silently falsifies the store's containment claims. No live caller tripped it (all five build the dict from field_companions), but the function's own docstring claims to be the seam that enforces exactly this. All three arms now walk COMPANION_CHANNELS, and an unrecognized kwarg is refused rather than silently dropped (normalizing through the table would otherwise return fewer slots than the caller zips).
  • Both-channel coverage (7e0a6a08, +504 lines across three test modules): the phase's whole point is N channels on one field, and nothing exercised two. New TestBothChannelsOverviewFold / TestBothChannelsStageSweep / TestBothChannelsColumn, plus a slot-order test over 0/1/3 contributors × forward/reversed dict. Mutation-verified rather than assumed — reversing the order reproduces the reported bug and fails 4 of the new tests across two modules. The stage tests also reach _companion_group's 2-of-3 branch, which was unreachable before.
  • The folded-column golden was swap-blind (0f153a94): it asserted shape and non-zero-ness, both of which survive exchanging the two sibling chunks. It now pins the column's words by value against the leaf cells beneath them — cell_envelope identity per channel and the common_ancestor hull — with a checked counter so it cannot pass vacuously.
  • Two of my own comments overclaimed and were corrected rather than argued: the waveform docstring said "per-centroid at every level" for a field that is class none and has no overview levels (cf699e0a), and the ATL03 yaml promised a reader that does not exist (8cc8bb50 — see the standing item above).

Questions for review

One new item, surfaced by the phase-4 fold — no reader binds the temporal channel.

There is no read path for {field}_times at all. readers.read_locations binds the ragged block's locations key only, and read_raw_values is not an alternative — it indexes digest[:, 1] and raises IndexError on a flat word vector (verified against the committed §7 fixture). So the channel is written, declared, folded and conformance-pinned, but a consumer has to open the sibling by hand.

That is fine for this PR's scope — the spec is what external readers decode from, and moczarr reads the bytes directly — but zagg's own reader surface is now asymmetric between the two companions. The shape of the fix is a channel= parameter on read_locations rather than a third near-duplicate sweep of the same ragged-open/decode path. Left standing rather than implemented inside a fold, since it is new public API. The yaml comment and readers/tdigest_tensor.py's layout header were corrected to state the gap accurately instead of promising a reader that does not exist.

Resolved or spun out

  1. The per-cell binding forkruled option (c) (espg, 2026-08-17, amending ruling 3): companions stay per-centroid at every level, symmetric with located; §8.4's reduction stays licensed for producers that want it. The binding question vanishes — there is no per-cell overview array to bind, so §8.2's missing binding grammar never has to be closed. What landed.
  2. Pre-2017 tai epoch splits the two conversions by 19 sspun out to issue windows.py: pre-2017 tai-labelled epoch conventions split word/router conversions by 19 s #469 (a windows.py epoch-convention question, not reachable at this seam). No shipped config is affected; all use the 2018 ATLAS SDP epoch.
  3. check_companion_match is not called from sweep_stage._merge_slabsspun out to issue sweep_stage._merge_slabs bypasses check_weights_match and check_located_match (attrs never surfaced) #470. The identical gap already exists for §2.0 (check_weights_match isn't called there either), so it is one pre-existing hole for both conventions rather than half-closed for §8.3/§9.
  4. No §7 fixture covers a folded companionclosed by this phase, and for free: the regenerated temporal/ fixture's §4.6 leaf column carries h_tdigest with both siblings at both resolutions, which is the fixture set's first golden for a companion produced by a merge rather than by ingest. test_the_columns_companions_are_row_aligned_and_declared asserts row alignment and both declarations on the committed bytes.
  5. output.time_source in the D19 semantic coreespg-blessed as landed (cb5d0648). Recorded rationale: the D19 hash epoch: granule_workers into DATA_SOURCE_PACKAGING_KEYS; semantic_core widened to the leaf-shaping output knobs (issue #415) #420 boundary applies — the declared epoch changes what is computed (the words are normative bytes), which makes it identity, not packaging; it is the temporal analogue of the §2.0 weights currency; conditional keying plus resolved recording means zero disturbance to pre-Per-centroid temporal companion for t-digests: a 64-bit hierarchical time cell, mirroring the spatial location companion #410 configs or the merged epoch; and California's temporal stores must be born with the clock in their identity, since adding it later would cost a second epoch.
  6. GEDI's delta_time timescale unverifiedverified empirically. BEAM0000/ancillary/master_time_epoch reads exactly 1,198,800,018.000000 on two independent real granules (GEDI01_B_2019128… and GEDI01_B_2020024…): the 2018-01-01 epoch in leap-aware GPS seconds (a naive UTC count would be 1,198,800,000; the +18 is GPS−UTC at 2018). So GEDI shares ICESat-2's convention, scale: gps is exact offset arithmetic, and the GEDI template carries a per-centroid companion. The constant is cited in the template comment and pinned by test_the_gedi_clock_is_the_verified_gps_epoch.

Coordination for @espg — cross-repo, NOT executed here

moczarr re-vendors tests/data/spec/temporal/ after this merges. §7 records that moczarr vendors these fixtures for its parity gates (espg/moczarr#19/#20) with espg/moczarr#23 as the divergence detector. This PR regenerated that fixture, so the vendored copy goes stale on merge. The moczarr #43 re-check is the natural vehicle. Nothing was done in that repo from here — it is a different repo, so it is flagged rather than actioned.

What moved, exactly, so the bump has something precise to follow:

leaf arrays (11213.zarr/6/*) byte-identical — only the commit stamp's written_at
all.pyramid.zarr (§4.6 column) gains h_tdigest, h_tdigest_locations, h_tdigest_times at resolutions 4 and 5
all.pyramid.stats.json six new per-array content hashes; combined moves to b6859b89…
morton_hive.json h_tdigest class noneapproximate (+ location, temporal, δ keys); observed stays none
FROZEN_COMBINED / FROZEN_ARRAYS / golden semantic-hash pin unchanged, not re-pinned — the leaf is what they cover

Interactions

@espg espg added the implement label Aug 17, 2026
Comment thread src/zagg/stats/tdigest.py Outdated
(``mortie.tocs_reduce``, the segmented ``toc_merge`` reduce, issue #177):
the conservative envelope containing every observation the centroid
summarizes, and — because the join is associative, commutative and
idempotent — **bit-identical under any fold tree**, which is what makes the

@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)

should-fix — the per-centroid channel is not "bit-identical under any fold tree", and §8.3 says so explicitly.

    idempotent**bit-identical under any fold tree**, which is what makes the
    channel merge-order-independent where the digest payload itself is only
    approximately so (spec §2.3/§8.3).

The join is a semilattice, but a per-centroid vector is indexed by the centroid partition, and that partition moves with the fold tree. docs/specification.md §8.3 draws exactly this line:

The companion's exactness does not lift the field's composability class: a merged payload's centroids may differ, and the words are exact given the centroid partition they describe, not independently of it.

and the design ledger says the same (#410 comment, point (b)): "the exactness of the toc merge cannot lift the field's composability class on its own."

Measured on this commit (4 digests of 200 obs, δ=32, ./.venv/bin/python):

kway k = 37  pairwise k = 41
per-centroid words bit-identical across fold laws? False
cell-level envelope identical?                     True
balanced-tree words == kway words?                 False
balanced cell envelope == kway cell envelope?      True

So the fold-tree invariance is a cell-level property (which is what licenses §8.4's per-centroidper-cell coarsening, and what test_cell_envelope_is_fold_tree_independent actually pins). The PR body gets this right — "a cell-level reduction is bit-identical under any fold tree" — the docstring drops the qualifier. Same sentence needs the same fix at merge_tdigests_kway, line 575: "so its words are bit-identical under any fold tree, not merely under a permutation of one flat k-way call."

Consequence: a phase-3 reader of this docstring could conclude an overview's per-centroid words are reproducible from any fold tree and skip pinning the fold order in a conformance fixture, which is precisely the failure §8.3's clause exists to prevent.

nit, same docstring (line 223): issue #177 is an espg/mortie issue, not a zagg one — pyproject.toml writes it correctly as espg/mortie#177. As written it reads as zagg#177.

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

Folded in e1703a5. The docstring no longer claims per-centroid fold-tree invariance; it now says the join buys cell-level invariance and that a per-centroid vector is indexed by the centroid partition, whose words are exact given that partition and not independently of it — quoting docs/specification.md §8.3 directly, and pointing at zagg.stats.toc.cell_envelope / test_cell_envelope_is_fold_tree_independent as the thing that actually holds.

The same sentence at merge_tdigests_kway is rewritten too: it now separates permutation-independence of one flat k-way call (which the tie keys do buy) from fold-tree independence (which only the cell-level envelope has). I also fixed a third instance you did not flag — the temporal1, temporal2 parameter doc on merge_tdigests, which said "exact and fold-tree independent, unlike the payload"; same overclaim, same commit.

The bare issue #177 is now espg/mortie#177. Checked the rest of the tree: the remaining #177 mentions (tests/test_lambda_handler.py, docs/deployment/lambda.md) are the genuine zagg issue and were left alone.

Comment thread src/zagg/stats/tdigest.py Outdated

The temporal channel is the exception the difference does not reach: its
join is associative and commutative (spec §8.3), so a pairwise left-fold
and a k-way fold produce bit-identical toc words.

@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)

should-fix — this claim is false, and falsifiable without subtlety: the two fold laws don't even return toc vectors of the same length.

    The temporal channel is the exception the difference does not reach: its
    join is associative and commutative (spec §8.3), so a pairwise left-fold
    and a k-way fold produce bit-identical toc words.

build_tdigest_pairwise differs from build_tdigest only in the cross-block fold law (merge_tdigests left-fold vs merge_tdigests_kway). The per-centroid vector is one word per output centroid, and the two laws produce different centroid counts — that is the whole point of the pairwise/k-way distinction (merge_tdigests "drifts above the δ budget", two lines above). Measured on this commit, 4 blocks × 200 obs, δ=32:

kway k = 37   pairwise k = 41
per-centroid words bit-identical across fold laws? False
cell-level envelope identical?                     True

What is true is the cell-level statement: cell_envelope over either vector gives the same word. Suggest the docstring say that instead — e.g. "the channel's cell-level envelope is the exception … zagg.stats.toc.cell_envelope over either fold's words returns the same token, though the per-centroid vectors differ with the partition (§8.3)".

Note this is the same overclaim as the one at line 226 / line 575, but here it is stated as a concrete equality between two named functions, so it is the one most likely to be relied on.

@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

Folded in 03dc7a2, taking your suggested phrasing. build_tdigest_pairwise now reads:

    The temporal channel does not escape that difference either: the two laws
    reach different centroid partitions (different counts, even), so their
    per-centroid toc vectors differ. What the join being a semilattice (spec
    §8.3) buys is the *cell-level* statement —
    :func:`zagg.stats.toc.cell_envelope` over either fold's words returns the
    same token — since the words are exact given the partition they describe,
    not independently of it.

The differing-length point is now stated first, since that is the falsifiable half.

Comment thread src/zagg/stats/tdigest.py
delta: int = ...,
*,
temporal1: np.ndarray,
temporal2: np.ndarray,

@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)

should-fix — the both-channels overload is missing on merge_tdigests and on merge_tdigests_kway, so declaring both channels at a merge site is a mypy error. build_tdigest (line 283) got the 4th overload; the two merge functions did not.

Verified with the pinned pre-commit mypy (mirrors-mypy v1.19.1, MYPYPATH=src):

error: No overload variant of "merge_tdigests" matches argument types ... [call-overload]
  note: Possible overload variants:
  note:     def merge_tdigests(d1, d2, delta=..., locations1: None = ..., locations2: None = ...) -> ndarray
  note:     def merge_tdigests(d1, d2, delta=..., *, locations1, locations2) -> tuple[ndarray, ...]
  note:     def merge_tdigests(d1, d2, delta=..., *, temporal1, temporal2) -> tuple[ndarray, ...]
error: No overload variant of "merge_tdigests_kway" matches argument types ... [call-overload]

(probe: merge_tdigests(d, d, 512, locations1=w, locations2=w, temporal1=w, temporal2=w) and merge_tdigests_kway([d], 512, locations=[w], temporal=[w]); the equivalent build_tdigest(..., locations=w, temporal=w) resolves cleanly to a 3-tuple.)

Consequence: phase 3 plumbs both channels through sweep_overview.fold_digests / _fold_child / _fold_node / sweep_stage._merge_slabs, i.e. exactly the both-channels merge call that has no matching overload — so pre-commit run --all-files will fail there and the fix will look like an unrelated typing change in a later phase. The runtime implementation already handles both together correctly (I ran it: merge_tdigests(empty, d3, 512, locations1=…, locations2=l3, temporal1=…, temporal2=t3) returns the right 3-tuple), so this is purely the missing declaration.

Also worth noting while you're in here: the located overloads' return narrowed from tuple[np.ndarray, np.ndarray] to tuple[np.ndarray, ...], which drops arity information for the existing 2-tuple call sites (processing/spill.py:863, processing/spill.py:964). mypy still accepts merged, merged_locs = merge_tdigests(...) against a variadic tuple (I checked), so nothing breaks — but a per-arity tuple[np.ndarray, np.ndarray] / tuple[np.ndarray, np.ndarray, np.ndarray] on each overload would keep what build_tdigest still has.

@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

Folded in 0c99c67. Both merge functions now carry the both-channels overload, and I took your second suggestion too — the single-channel overloads are back to per-arity returns instead of the variadic tuple[np.ndarray, ...], matching what build_tdigest still has:

  • merge_tdigests: located → tuple[ndarray, ndarray], temporal → tuple[ndarray, ndarray], new *, locations1, locations2, temporal1, temporal2tuple[ndarray, ndarray, ndarray].
  • merge_tdigests_kway: same shape, with the new *, locations, temporal overload.

Verified against the pinned pre-commit mypy (mirrors-mypy v1.19.1 with the hook's numpy==2.1, MYPYPATH=src) on your exact probes. Both [call-overload] errors are gone and the returns now reveal as expected:

merge_tdigests(d, d, 512, locations1=w, locations2=w, temporal1=w, temporal2=w)
  -> tuple[ndarray, ndarray, ndarray]
merge_tdigests_kway([d], 512, locations=[w], temporal=[w])
  -> tuple[ndarray, ndarray, ndarray]
merge_tdigests(d, d, 512, locations1=w, locations2=w)
  -> tuple[ndarray, ndarray]
merge_tdigests_kway([d], 512, temporal=[w])
  -> tuple[ndarray, ndarray]

The same run also flagged one more error inside merge_tdigests that phase 1 introduced — error: Item "function" of "Callable[...] | ndarray" has no attribute "copy" [union-attr] on c[keep].copy(), because keep doubles as the digest selector and the tuple index into (reducer, c1, c2). Since it is the same function and the same check, I fixed it in the same commit by dropping the index magic:

        left = d2.size == 0
        kept = np.asarray(d1 if left else d2, dtype=np.float32)
        if not channels:
            return kept
        return (kept, *((c1 if left else c2).copy() for _, c1, c2 in channels))

Left standing, flagged rather than folded: build_tdigest_pairwise still reports Argument "locations"/"temporal" to "build_tdigest" has incompatible type "ndarray | None"; expected "None" [arg-type]. That is pre-existing — the pre-phase-1 body had the identical build_tdigest(values, delta=delta, locations=locations) shape against the identical overload set, so phase 1 only doubled an error that was already there. Fixing it means the kwargs dict trick build_tdigest_where uses, which is a wider change than this finding. Worth noting mypy is not in CI here (lint.yml runs ruff only; mypy is pre-commit-local, and the hook env reports ~198 errors tree-wide including missing mortie/boto3/h5coro stubs), so none of this was gating — but the declaration was still wrong.

Comment thread src/zagg/stats/tdigest.py
A silent ``uint64`` cast would turn a mis-declared float column into
plausible-looking morton or toc words, so both channels require packed
``uint64`` outright — what ``HealpixGrid.assign`` supplies as ``leaf_id``
and what :func:`zagg.time_axis.observation_words` supplies as toc words.

@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)

should-fixzagg.time_axis.observation_words does not exist.

    plausible-looking morton or toc words, so both channels require packed
    ``uint64`` outrightwhat ``HealpixGrid.assign`` supplies as ``leaf_id``
    and what :func:`zagg.time_axis.observation_words` supplies as toc words.

Repo-wide, this docstring is the only occurrence of the name:

$ grep -rn "observation_words" src tests tools docs
src/zagg/stats/tdigest.py:261:    and what :func:`zagg.time_axis.observation_words` supplies as toc words.

src/zagg/time_axis.py defines time_encoding, time_axis_dtype, time_axis_attrs, temporal_declaration_block, temporal_attrs, temporal_declaration, _internal_ns, encode_time_axis, decode_time_axis, time_axis_overlaps, read_time_axis — no observation_words. The HealpixGrid.assign half of the same sentence is real, which makes the toc half read as a live reference rather than a placeholder.

Consequence: a dead :func: role (broken cross-ref if these docstrings are ever rendered), and if phase 2's per-observation encode lands under a different name the docstring silently keeps pointing at nothing. Either drop the reference or phrase it forward-looking ("the per-observation toc encode phase 2 adds").

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

Already resolved by later work on the branch — no edit needed, and the reference is live now.

zagg.time_axis.observation_words was added by phase 2 in a4e123a, after this review was taken:

$ git log --oneline -S "def observation_words" -- src/zagg/time_axis.py
a4e123a8 phase 2 of issue #410

$ grep -n "observation_words" src/zagg/time_axis.py
135:    "observation_words",
296:def observation_words(values, *, epoch, scale: str, units: str) -> np.ndarray:

It is in __all__, and its own docstring is "Per-observation offsets -> exact toc timestamp words (§8.3)" — i.e. exactly what the _check_words docstring said it supplies. So the :func: role resolves and the forward-looking rephrase you offered as the alternative is not needed.

The finding was correct at the sha it was written against; leaving the docstring as-is rather than churning it.

Comment thread src/zagg/stats/tdigest.py Outdated
from mortie import tocs_reduce

starts = np.asarray(starts, dtype=np.int64)
if temporal.size and not temporal.all():

@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)

nit — the zero test itself is right, but its placement leaves the reserved word un-refused on exactly the paths that don't reduce.

The predicate is correct for uint64 (0 is the only falsy word, .all() short-circuits nothing but is a single vectorized pass, and the temporal.size guard covers the empty case) — and refusing rejects nothing legitimate. I confirmed the grammar side against mortie 0.9.9:

time2toc(0)            -> [2147483648]      # epoch instant, flag bit at 31
span2toc(0, 0)         -> [1]               # shortest range word
span2toc(0, 1)         -> [1]

so 0 is unproducible, matching §8.2's "The reservation is cost-free".

The gap: the check lives in _centroid_envelopes, which is only called when a reduction actually happens. The pass-through arms skip it:

merge_tdigests(d, empty, 512, temporal1=<vector with a 0>, temporal2=empty)
  -> passthrough with reserved 0: NOT refused -> 0
merge_tdigests_kway([d, empty], delta=512, temporal=[<vector with a 0>, empty])
  -> kway single-contributor reserved 0: NOT refused -> 0
merge_tdigests(d, d, 512, temporal1=<vector with a 0>, temporal2=t)
  -> refused: "temporal words contain the reserved 0 word (spec §8.2's unob…"

A single-block cell (the common spill shape) and a single-contributor overview fold therefore write the reserved word straight through, which is the one thing §8.2 says a writer MUST NOT store for an observed cell. The natural home is _check_words (line 255) — it already runs on every channel argument on every arm including the pass-throughs, and it's where toc.py::_checked puts the same check. That would also collapse the current duplication: two independent implementations of "refuse the reserved 0" with two different messages ("temporal words contain the reserved 0 word (spec §8.2's unobserved marker); …" here vs "toc words contain the reserved 0 word (spec §8.2's unobserved-cell marker); …" in toc.py), only one of which the tests/test_stats_toc.py / tests/test_tdigest.py match="reserved 0 word" assertions distinguish.

(If the split is deliberate — _check_words being channel-agnostic and 0 being a toc-only reservation — a one-line comment saying so would stop the next reader from "tidying" it.)

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

Folded the placement half in 36812c3; declined the cross-module de-duplication half, with a comment left behind saying why.

Folded — the check moved to _check_words. Your read of the gap is right, so the refusal now sits in the one validator every arm runs, and _centroid_envelopes no longer carries it. That closes both pass-throughs you measured, and the message stayed on the reserved 0 word substring the existing assertions pin. Pinned in the same commit:

  • test_pass_through_arms_still_refuse_the_reserved_zero — all three arms: merge_tdigests(d, empty, temporal1=leaked, ...), the mirrored merge_tdigests(empty, d, ..., temporal2=leaked), and merge_tdigests_kway([d, empty], temporal=[leaked, empty]).
  • test_reserved_zero_refusal_is_channel_agnostic — the located channel now refuses a leaked fill on its pass-through as well. That is a deliberate tightening beyond the finding: 0 is no more a valid morton word than a toc word (validate_morton rejects it), and the located channel had the identical hole because common_ancestor also only runs when a reduction happens. Ran pytest -k "digest or spill or streaming or toc or overview or sweep" — 825 passed, so nothing was relying on a zero surviving a pass-through.

Declined — collapsing toc.py::_checked into _check_words. Judgment call, and it stays standing for review. _check_words's second job is aligning a channel against a digest's centroid count, a shape the per-cell reducers have no analogue for, so sharing it means making that argument optional and importing a private symbol from the digest kernel into the module whose docstring exists to say it owns the coarser shape. It would also cost toc.py its TOC_UNOBSERVED self-documentation (the shared message hardcodes 0) and churn two public error messages for no behavioral gain — the duplication is four lines.

Taking your own suggestion for that case instead: _checked now says so out loud, so the next reader does not tidy it —

    Deliberately separate from the digest kernel's
    :func:`zagg.stats.tdigest._check_words`, which makes the same refusal: that
    one's other job is aligning a channel against a digest's centroid count, a
    shape these per-cell reducers have no analogue for, and this module names the
    marker through :data:`zagg.time_axis.TOC_UNOBSERVED` rather than a literal.

Thanks for the mortie 0.9.9 probes confirming 0 is unproducible — that is what makes the refusal safe to hoist to the validator, where it now runs on every declared channel rather than only on the reducing arms.

Comment thread tests/test_tdigest.py
)
np.testing.assert_array_equal(merged, d)
np.testing.assert_array_equal(out, t)
assert out is not t, "the channel must not alias the caller's array"

@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)

nit (test strength)TestTemporalMergeLaws never exercises merge_tdigests with two non-empty sides. The three tests that touch it are the arity raise (line 971), this pass-through (line 975), and nothing else — so the pairwise merge's temporal fold, the

    joined = [np.concatenate([c1, c2])[order] for _, c1, c2 in channels]

_centroid_envelopes(words, starts, n) path at lines 526/536, has no correctness assertion on the channel at all. The lines are covered (98% line + branch on stats/tdigest.py from tests/test_tdigest.py tests/test_stats_toc.py), but only by the pre-existing located tests going through the same channel-agnostic loop; nothing asserts a merged toc word contains its members, or that the two channels stay row-aligned with each other, through the pairwise law.

That matters because merge_tdigests is the law two shipped call sites use — processing/streaming.py:342 and processing/spill.py:873 — and it is a distinct code path from the k-way one that test_kway_* covers (different concatenate, different tie-break: merge_tdigests uses argsort on means with no channel tie key, merge_tdigests_kway uses the 4-key lexsort).

The cheapest fix mirrors the build-path test one class up: two overlapping-value digests with known member instants, merge_tdigests(d1, d2, delta=8, temporal1=t1, temporal2=t2), then assert every output word's decoded [start, end) contains its contributors' words via mortie.toc_contains / toc_overlaps — no need to reconstruct the partition. Adding both channels at once to the same call would also cover the zip(channels, joined, strict=True) pairing, which is currently only pinned k-way (test_kway_both_channels_independent_of_each_other).

Two smaller notes in the same vein, both genuinely fine at runtime — I checked each by hand — but unpinned:

  • merge_tdigests's keep = 1 if d2.size == 0 else 2 / c[keep] index magic (line 517). The keep == 2 arm is covered for the located channel (line 360, merge_tdigests(empty, d1, locations1=…, locations2=l1)) but not for temporal, and keep doubles as both the digest selector and the tuple index into (reducer, c1, c2). I verified all four emptiness combinations return the right channel, un-aliased, with one and two channels declared. A merge_tdigests(empty, d, temporal1=…, temporal2=t) case would pin the arm that a channel-swap regression would break silently.
  • build_tdigest_where's channel shape check (line 411) is the one new statement in stats/tdigest.py with no coverage from either new test file; test_mismatched_lengths_raise only exercises the build_tdigest copy of the check.

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

Folded in fe05788 — all three gaps, plus the both-channels pairing you noted in passing.

The two-non-empty-sides temporal fold (test_pairwise_merge_of_two_sides_envelopes_contain_their_members): two overlapping-value digests from _parts(k=2, n=150) merged at delta=8, asserting len(out) == len(merged) < len(t1) + len(t2), that mortie.toc_is_range(out).any() (so the compressing path really ran), and that every input word is covered by some output envelope via decoded [start, end) bounds. I used toc2time bounds rather than toc_contains, because toc_contains takes an integer window and a timestamp envelope decodes to [t, t] — an empty half-open window, which contains nothing — so the containment predicate has to handle the timestamp case explicitly. Same convention the sibling build-path test at line 864 already uses.

The both-channels pairwise pairing (test_pairwise_merge_both_channels_independent_of_each_other): mirrors test_kway_both_channels_independent_of_each_other onto the pairwise law — each channel byte-identical to declaring it alone, digest byte-identical to declaring neither. A swap in zip(channels, joined, strict=True) would leave each vector reduced by the other channel's law, so neither would match.

The keep == 2 arm for temporal (test_pairwise_merge_passes_the_right_side_through): merge_tdigests(empty, d, temporal1=empty_w, temporal2=t), asserting the digest and channel come back as d / t and that the channel is not aliased. Note the arm itself no longer uses the keep index magic — 0c99c67 replaced it with an explicit left = d2.size == 0 when fixing the [union-attr] mypy error on c[keep].copy(), so the selector and the tuple index are no longer the same variable.

build_tdigest_where's channel shape check (test_where_reducer_refuses_a_mismatched_channel): pins the build_tdigest_where copy with match="temporal shape .* does not match values shape", which is tight enough to distinguish it from the build_tdigest copy test_mismatched_lengths_raise already covers.

pytest tests/test_tdigest.py tests/test_stats_toc.py is 139 passed.

Comment thread tests/test_stats_toc.py Outdated

def test_empty_group_raises(self):
words = _toc_words(6)
with pytest.raises(ValueError, match="empty segment|empty"):

@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)

nitmatch="empty segment|empty" collapses to match="empty": the second alternative subsumes the first, so the assertion passes on any ValueError whose message merely contains the substring "empty" — including one raised for an unrelated reason (a future numpy "empty array" message, say), and including one raised before the reduce is reached.

The message mortie 0.9.9 actually raises here is stable and specific:

ValueError: group 1: tocs_reduce of an empty segment (the merge has no identity element)

so match="empty segment" alone (or match="no identity element", which matches cell_envelope's own wording too) pins what the test means to pin. Worth noting the sibling test one class up uses the tight form — match="no identity element" at line 63 — so this is just the loose one.

While here: mortie validates the rest of the offsets contract that cell_envelopes delegates, and does it well — I probed the three shapes zagg passes through unchecked and all three raise ValueError with the offending index named (offsets must be monotonically non-decreasing (2 < 4), offsets must end at the word count: offsets[1] is 3 but words has 6 entries, group 1: offset 99 exceeds word array length 6). So the decision not to re-validate offsets in cell_envelopes looks right; no finding there.

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

Folded in be1b640match="empty segment|empty" is now match="empty segment", matching the sibling match="no identity element" at line 63 for tightness. Kept empty segment rather than no identity element because that is the wording mortie raises on this path (group 1: tocs_reduce of an empty segment ...), so the assertion pins the message the delegated reduce actually produces.

Noted on the offsets contract — no change made there, and none was asked for.

@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

P0 evidence: the per-centroid toc byte cost, measured on real ATL03

The consolidated plan's step (1) — "Sierra tail subset: per-centroid toc byte cost post-zstd … and the delta_time read-side cost" — was unrun. It is now run, but on a different subset than the plan named, and that substitution is the first thing to check:

What I could not use. There are no Sierra-tail artifacts in the tree (grep -ril sierra finds only two planning markdowns; data/ carries california/, conus/, neon/, gedi_debug/ and the 623 MB atl03_v007/ catalog clone, which is metadata — granule bounds and footprints, no photons).

What I used instead. The local repeat-track-1336 stack — 29 real ATL03 v007 granules, cycles 01–30, 2018-12-25 → 2026-03-11, 56 GB, beam gt2l. For this question repeat-track is arguably the better population than an arbitrary AOI: one order-19 cell holds photons from several passes years apart, which is exactly what a per-centroid toc word has to summarize, and exactly the structure that makes its low bits incompressible.

Geometry is the shipped located config's, unmodified — src/zagg/configs/atl03_tdigest_located_healpix.yaml: parent_order: 11, chunk_inner: 13, child_order: 19, delta: 8192. Bytes are priced per 4,096-cell inner chunk through the spec §1.3 chain [vlen-bytes, zstd(level=3)] — the chain zagg writes. Words come from the production encoder (time_axis.observation_words) and the production kernel (build_tdigest(..., locations=, temporal=)).

Result — two independent along-track bands

band 70.00–70.05 band 75.00–75.05
granules contributing 17 18
photons 647,300 710,857
populated order-19 cells 3,021 1,955
obs/cell mean · median · p99 · max 214 · 63 · 1,193 · 1,323 364 · 243 · 987 · 1,065
cells loss-free at δ=8192 3,021 / 3,021 1,955 / 1,955

Stored bytes per centroid (post-zstd; k == n here, so B/centroid == B/photon):

array raw band 70 stored band 75 stored zstd ratio
h_tdigest payload, f32 (k,2) 8 B 1.80 B 1.74 B 4.4–4.6×
h_tdigest_locations, u64 8 B 2.39 B 2.30 B 3.3–3.5×
h_tdigest_times, u64 (toc) 8 B 2.08 B 2.01 B 3.9–4.0×
located field total 16 B 4.19 B 4.04 B
+ toc 24 B 6.27 B 6.05 B
toc's delta on a located field +50.0% +49.7% +49.6%

What this settles

  1. The +50% figure is real, and it is +50% stored, not just raw. Both bands land within 0.1 pp of each other and of the synthetic estimate posted earlier on this thread (+49% saturated / +57% below the knee). That comment's negative conclusion is confirmed measured rather than estimated: temporal locality does not pay for itself in compression. toc compresses 3.9–4.0×, barely better than its morton sibling's 3.3–3.5× and worse than the payload's 4.4–4.6×, because the low 31 bits of a timestamp word are sub-2.15 s nanosecond residue — incompressible noise.
  2. The "24 B/photon lossless columnar store" concern is correct on the raw figure and 3.8× smaller in the store. That same comment's warning — "Per-centroid temporal companion for t-digests: a 64-bit hierarchical time cell, mirroring the spatial location companion #410 + Raise the t-digest compression factor so a single crossing's observations survive uncompressed #414 together convert the t-digest from a δ-bounded sketch into a lossless columnar store of (height, weight≡1, location, time) at 24 B/photon" — is exactly what these cells are: every cell in both bands is loss-free at δ=8192 (k == n, 4,976 cells, zero exceptions), at 214–364 obs/cell against a 8,192 budget. The store cost of that is 6.05–6.27 B/photon, against 4 B for the raw h_ph float32 the field summarizes. So the product decision the comment asked to be made deliberately is: ~1.55× the raw height column, for height + weight + exact order-29 position + exact-ns time, per photon.
  3. The obs/cell histogram #414's open question (2) asked for. Median 63 and 243 in the two bands, p99 ≈ 1,000–1,200, max 1,323. Two orders of magnitude below δ=8,192 — so at order 19 in this region Raise the t-digest compression factor so a single crossing's observations survive uncompressed #414's raise is already fully absorbed and leaves are below the knee today, which is the reordering that comment anticipated ("If it is already ≲δ, then leaves are below the knee today, Raise the t-digest compression factor so a single crossing's observations survive uncompressed #414 is close to a no-op at leaf level"). Caveat: repeat-track high-latitude, 29 cycles; a dense mid-latitude AOI or the 88S convergence will sit higher.

The read-side half, and its caveat

delta_time on ATL03 is a photon-rate column (/{beam}/heights/delta_time, float64) — no segment broadcast needed, so ingest is a plain extra column read:

bytes read
lat_ph + lon_ph + h_ph (f64+f64+f32 = 20 B/photon) 110.0 MB / 128.0 MB
delta_time (f64 = 8 B/photon) 44.0 MB / 51.2 MB
delta_time's share +40.0% of the base-rate read volume (both bands)

Wall time is NOT measured here and the local number should not be quoted. These are local-disk reads (+16.4–16.9% wall), and the memory that matters is that reads are the wall — over h5coro/S3, per-column byte-range behavior, request count and concurrency dominate, none of which a local h5py slice exercises. What is transferable is the +40% base-rate byte volume; pricing its wall cost needs a fleet run, which is @espg's to execute (not something a local harness can substitute for).

Reproducing

/private/tmp/.../scratchpad/p0_410_bytes.py (+ the _b75 band variant) and their p0_410_result*.json. They are scratch, not committed: they read from /Users/espg/ignore/atl03_1336_r05, which is outside the tree and not publicly reachable, so a committed benchmark pointing at it would fail for everyone else (CLAUDE.md §4's runnable-data rule). If this measurement should become a committed artifact, it needs a publicly-reachable subset first — happy to do that as a follow-up if wanted.

Comment thread src/zagg/time_axis.py Outdated
# ``utc_to_offset`` of the epoch against itself is 0 for a post-2017 epoch
# and the scale-UTC offset for a pre-2017 one — windows.py's branch, reused
# rather than restated.
shift_s = utc_to_offset(epoch_dt, epoch=epoch_dt, scale=scale, units="seconds")

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)

blockingshift_s double-counts the leap offset. For a pre-2017 epoch the stored word is 18 s (gps) / 37 s (tai) off the instant and 18 s away from what windows.offset_to_utc places the same observation at — the exact hazard this design is the answer to.

shift_s = utc_to_offset(epoch_dt, epoch=epoch_dt, scale=scale, units="seconds")
...
offsets = np.asarray(values, dtype=np.float64) * UNIT_SECONDS[units] - shift_s

base is not a naive count. _internal_nsmortie.from_datetime64 is leap-aware, and mortie's internal scale is the continuous GPS scale (GPS − UTC = 0 at its 1980-01-06 alignment):

UTC instant _internal_ns naive ns since 1850 diff
1850-01-01 0 0 0
1980-01-06 4102790400000000000 4102790400000000000 0
2017-01-01 5270054418000000000 5270054400000000000 +18 s
2018-01-01 5301590418000000000 5301590400000000000 +18 s

So base already carries the scale correction, and the correct conversion for a continuous-scale column is internal = base + v·1e9, with no shift at all. Measured with scale: gps, epoch 1980-01-06 (a GPS-native column — the case the comment above says the branch exists for) and v = 1.4e9 (a 2024 observation):

word (current)  = 2024-05-17T16:52:44
word (no shift) = 2024-05-17T16:53:02   <- the true instant
router says     = 2024-05-17T16:53:02   (windows.offset_to_utc)
current vs router = -18 s

Repro:

import numpy as np, mortie
from zagg.time_axis import observation_words
from zagg.windows import offset_to_utc
w = observation_words(np.array([1.4e9]), epoch="1980-01-06T00:00:00", scale="gps", units="seconds")
print(mortie.to_datetime64(mortie.toc2time(w)[0])[0])                    # 2024-05-17T16:52:44
print(offset_to_utc(1.4e9, epoch="1980-01-06T00:00:00", scale="gps"))    # 16:53:02+00:00

18 s is ~8 quanta of the range variant's ~2.15 s start grid, so this is precisely "an observation routed into window W whose stored word reads as outside W" from the Q4 discussion on #410 — and §8.3's "MUST encode an instant as a timestamp word, exact to the nanosecond" is missed by 18 s, not by rounding.

Two notes on the surrounding rationale:

  • The docstring's "the scale-vs-UTC correction for a pre-2017 epoch is taken from zagg.windows.utc_to_offset rather than recomputed here, so the toc words and the window routing cannot drift apart" is falsified by the number above: the shift does not achieve router agreement, it creates an 18 s disagreement. Dropping it makes the word exact and makes word and router agree for every post-2017 observation, leaving only windows.py's own documented ≤ 1-leap-second tolerance, which nothing at this seam can fix.
  • scale's load-bearing role here is the TOC_SOURCE_SCALES refusal of utc. utc_to_offset's quantity (scale − UTC, era-gated on the epoch) is simply not the quantity a leap-aware base needs; for a UTC-labelled epoch, v counts SI seconds from an instant base already locates, on either continuous scale.

No shipped template is affected: ICESat-2's 2018 ATLAS SDP epoch is post-2017, so shift_s == 0 there — which is also why the suite is silent about it (separate comment on the test).

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

Folded in c602d1f8shift_s is gone; the conversion is internal = base + v·1e9 with no correction, exactly as derived. Your repro now prints 2024-05-17T16:53:02 for the word, matching windows.offset_to_utc. The shipped 2018-epoch path is byte-identical (shift_s was 0 there), and tests/test_spec_conformance.py::TestTemporalCompanions::test_the_production_kernel_reproduces_the_committed_words still passes against the committed §7 fixture words.

The docstring paragraph you quoted is rewritten to say why no correction belongs here — mortie.from_datetime64 is leap-aware, so base already places the epoch on the continuous scale and values counts SI seconds forward from it — instead of claiming the correction buys router agreement.

One residue recorded rather than hidden: word/router agreement is now exact for a post-2017 epoch and for the GPS-native case you measured, but a pre-2017 tai-labelled epoch still splits by 19 s, because windows.offset_to_utc reads a pre-2017 non-UTC epoch as native to its own scale (seconds -= 37) while the word takes the epoch string as the UTC instant values counts from. The old code was 18 s off the router there too, in the other direction. That is a windows.py epoch-convention question rather than something this seam can bridge, so the docstring names it explicitly and it stands for review.

Comment thread tests/test_time_axis.py
out = self._words([])
assert out.shape == (0,) and out.dtype == np.uint64

def test_agrees_with_the_window_router_at_a_boundary(self):

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)

should-fix — this test cannot catch the bug it exists to prevent, because every case in TestObservationWords uses EPOCH = "2018-01-01T00:00:00", and utc_to_offset(epoch, epoch=epoch, ...) returns 0.0 for any epoch on/after 2017-01-01. So shift_s == 0 in all ten tests: the pre-2017 branch of observation_words — the only branch where the correction does anything, and the one that is wrong (see the comment on src/zagg/time_axis.py line 328) — is never executed. Flipping the sign of - shift_s, or deleting it, leaves the whole class green.

test_round_trips_to_the_declared_instants has the same blind spot from the other side: expected is re-derived as np.datetime64(EPOCH) + rint(offsets * 1e9), i.e. the code's own naive-nominal formula, so it pins the round trip only where the correction is inert.

Cheapest fix that would have caught it — one case on a native GPS epoch, asserted against windows.offset_to_utc rather than against a re-derivation:

def test_agrees_with_the_window_router_for_a_pre_2017_epoch(self):
    import mortie
    from zagg.windows import offset_to_utc
    epoch, v = "1980-01-06T00:00:00", 1.4e9          # GPS-native epoch, a 2024 observation
    word = self._words([v], epoch=epoch)
    decoded = mortie.to_datetime64(mortie.toc2time(word)[0])[0]
    assert decoded == np.datetime64(
        offset_to_utc(v, epoch=epoch, scale="gps").replace(tzinfo=None), "ns"
    )

Today that asserts 2024-05-17T16:52:44 == 2024-05-17T16:53:02 and fails by 18 s.

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

Folded in e6e6221f. Added test_pre_2017_epoch_is_not_leap_shifted on the GPS-native epoch you suggested, asserting both against the literal instant (2024-05-17T16:53:02) and against windows.offset_to_utc, so a correction reintroduced with either sign fails by 18 s. The other half you named is fixed too: test_round_trips_to_the_declared_instants now compares against three literal datetime64[ns] instants instead of re-deriving epoch + rint(offsets * 1e9), with a comment recording that the re-derivation was the blind spot.

Comment thread src/zagg/time_axis.py
Returns ``{"field", "epoch", "scale", "units"}`` with defaults resolved, or
``None`` when nothing is declared. **An absent block falls back to
``output.windowing``** when that block already carries a continuous-scale
clock: window routing and toc ingest then derive from ONE declaration, which

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)

should-fix — the fallback single-sources the clock only when output.time_source is absent. When both blocks are declared, nothing compares them, so a store can carry two conventions after all — the failure this paragraph says the fallback prevents.

Measured against validate_config (accepted, no warning):

output = {
    "windowing":   {"schedule": "yearly", "time_field": "delta_time",
                    "epoch": "2018-01-01T00:00:00", "scale": "gps", "units": "seconds"},
    "time_source": {"field": "other_time", "epoch": "2020-06-01T00:00:00",
                    "scale": "tai", "units": "days"},
}
validate_config(cfg)            # passes
toc_source(cfg)                 # -> {'field': 'other_time', 'epoch': '2020-06-01T00:00:00', ...}

Window routing then filters on delta_time against a 2018 epoch while every stored word is encoded from other_time against a 2020 epoch on a different scale in different units — unbounded disagreement, not a one-quantum edge effect. _validate_time_source checks shape, vocabulary and column membership, but never cross-checks the windowing block; toc_source reads whichever is present and prefers time_source.

Two options, either fine by me:

  1. When both blocks exist, require the four normalized keys to agree (or at least field + epoch), naming both declarations in the message. This makes the docstring's claim true rather than aspirational.
  2. Keep them independent but say so here — drop "which is what keeps them from disagreeing at a window boundary" and state that a store declaring both is responsible for their agreement.

Related, worth a sentence either way: the sentence below ("A windowed store on scale: utc gets no fallback ... and must declare a continuous column explicitly") is the one path that deliberately ends with two declarations of the same column on different scales. That is harmless for a post-2017 epoch (utc_to_offset differs between utc and gps only for a pre-2017 epoch) and an 18 s split for a pre-2017 one — the same 18 s as the shift_s finding on line 328, so whatever lands there should decide this sentence too.

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

Folded option (1) in f666ec7c. config._validate_time_source now cross-checks the two blocks whenever both are declared and output.windowing carries a continuous clock: field/scale/units compared directly, epoch compared as parsed instants (the windowing block's is canonicalized to ...+00:00), and the message names every disagreeing key with both values. The config you measured now raises instead of validating.

scale: utc windowing is exempt by construction — the check only runs when the windowing scale is in TOC_SOURCE_SCALES — because that is the path that must declare a continuous column separately. Pinned by test_both_clock_declarations_must_agree and test_a_utc_windowing_block_is_exempt_from_the_cross_check; the toc_source docstring now states the cross-check and the exemption rather than implying the fallback covers the both-declared case.

On your closing paragraph: with the shift gone (c602d1f8) the utc-windowed pairing is no longer an 18 s split at a pre-2017 epoch — routing on scale: utc places v at epoch + v and so does the word, so they agree. The tai pre-2017 case still differs by 19 s, for the windows.py-convention reason noted on the shift_s thread.

Comment thread src/zagg/config.py
f"reserved name of the derived toc word column output.time_source "
f"materializes (spec §8.2/§8.3) — rename the column"
)
ds_vars = ds_vars | {TOC_WORD_COLUMN}

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)

should-fix — validation and materialization disagree about when toc_word exists, so a config can name it, validate clean, and then die at runtime.

Here the column is added to ds_vars whenever toc_source(config) is not None — which, via the output.windowing fallback, is every windowed store on gps/tai, temporal companion or not. But aggregate.calculate_cell_statistics materializes it only when a field declares one:

if n_obs and _temporal_fields(agg_fields):
    cell_data = {**cell_data, TOC_WORD_COLUMN: _toc_word_column(cell_data, config)}

So with a plain windowed ICESat-2 config and no temporal: anywhere:

# aggregation.variables
"t_max": {"function": "numpy.max", "source": "toc_word", "dtype": "uint64", "fill_value": 0}

validate_config(cfg) passes, and the first cell raises KeyError: 'toc_word' inside calculate_cell_statistics. Same shape via the precompute hook, which reserves the name but now also admits it as a readable column:

aggregation["chunk_precompute"] = {"tmax": {"expression": "toc_word.max()"}}
# validate_config: accepted;  _eval_chunk_precompute: NameError: name 'toc_word' is not defined

The comment above ("it is a real base-rate column everywhere one is valid") and aggregate.py's ("Materialized only when a field declares a companion") are the two halves of the contradiction. Either condition works, they just have to be the same one:

  • materialize on toc_source(config) is not None (matches this validation, costs one encode pass on windowed runs that never read it), or
  • gate this ds_vars addition on any(m.get("temporal") for m in config.aggregation.get("variables", {}).values()) (keeps the encode off configs that don't ask for it, and keeps _validate_chunk_precompute's name reservation unconditional, which is the right side for a reserved name).

I'd take the second: the reservation should be unconditional, the readability shouldn't.

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

Folded your option (2) in ea9d3297. The ds_vars widening is now gated on any(m.get("temporal") ...) over aggregation.variables — exactly the condition aggregate.calculate_cell_statistics materializes on — and the name reservation moved out of the toc_source(config) is not None branch, so it is unconditional like leaf_id.

Both of your repros now fail at validation rather than in the worker: source: toc_word with no temporal: anywhere gives Variable 't_max': source 'toc_word' not in data_source.variables, and the precompute form gives expression references 'toc_word' which is not in data_source.variables or numpy namespace. Declaring a companion makes both resolve. Pinned by test_derived_column_is_only_readable_where_it_is_materialized and test_chunk_precompute_cannot_read_the_unmaterialized_derived_column; test_declared_column_may_not_shadow_the_derived_name gained an unclocked config so the reservation's unconditionality is pinned too.

Comment thread src/zagg/time_axis.py Outdated
# overflows the cast (numpy warns and yields garbage) where the grammar's
# own limits are what the caller needs named. The window is the timestamp
# variant's own domain — ns since the epoch, below the 63rd bit.
ns = np.rint(offsets * 1_000_000_000.0)

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)

should-fixnp.rint(offsets * 1e9) is not the nearest nanosecond at ICESat-2 magnitudes, which is the one number §8.3 states as a MUST ("a writer MUST encode an instant as a timestamp word, exact to the nanosecond") and this function's docstring restates ("exact timestamp words").

By 2026 delta_time is ~2.5e8 s, so offsets * 1e9 lands near 2.5e17 where a float64 ulp is 32 ns. The multiply therefore quantizes before rint ever runs, and the result can be up to 16 ns off the nearest ns of the float64 offset it was handed. Measured over 20 000 draws in [2.4e8, 2.5e8), compared against Fraction(v) * 10**9 (exact):

rint(v * 1e9)      max |err| = 16 ns
floor/frac split   max |err| =  0 ns

The fix is local and keeps everything else:

whole = np.floor(offsets)
ns = whole * 1_000_000_000.0 + np.rint((offsets - whole) * 1_000_000_000.0)

(whole is exactly representable and offsets - whole is in [0, 1), so both products are exact/nearest.)

Two framing notes so this isn't over-read: the source column is itself float64, whose own ulp at 2.5e8 s is ~30 ns, so no writer can be closer to the true instant than that — this is about the avoidable half, i.e. being the nearest ns of the value the reader actually delivered, which is the strongest claim a writer can make and the one the docstring makes. And units: days is worse in the same way (a ~2900-day offset multiplies to the same 1e17 range through one extra rounding).

If instead the intent is that ~30 ns is inherent and the MUST is read as "timestamp variant, not widened into a range", then the docstring should say that rather than "exact", because an external reader decoding from §8 + the fixtures will take "exact to the nanosecond" literally.

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

Folded in 480ee86b — the floor/frac split as you wrote it, with one addition: the final combine is done in int64 (base + whole * 10**9 + frac_ns) rather than float, because the float sum near 2.5e17 cannot hold an arbitrary nanosecond integer either — same 32 ns ulp — so keeping it in float would have re-lost up to 16 ns at the last step. Re-measured against Fraction(v) * 10**9 over 20 000 draws in [2.4e8, 2.5e8): max |err| 0 ns, against 16 ns for the old form. Pinned by test_is_exact_to_the_nanosecond_at_icesat2_magnitudes, which checks 2 000 words against exact rational arithmetic.

The float sum survives only as the pre-cast domain-check figure, and that check itself moved to exact Python ints in c3f0ab0e. The committed §7 fixture words are unchanged — test_the_production_kernel_reproduces_the_committed_words still passes.

Comment thread src/zagg/time_axis.py Outdated
# own limits are what the caller needs named. The window is the timestamp
# variant's own domain — ns since the epoch, below the 63rd bit.
ns = np.rint(offsets * 1_000_000_000.0)
ceiling = float(2**63 - 1 - base)

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)

nit — the ceiling is not the grammar's domain, so the pointed message doesn't fire in the last 4.29 s of the range. mortie refuses at 2**63 - 2**32, not 2**63 - 1:

>>> mortie.time2toc(np.array([2**63 - 1], dtype=np.uint64))
ValueError: timestamp 9223372036854775807 ns is at or beyond the toc span ceiling (2^63 - 2^32 ns ...)

so for an offset landing in [2**63 - 2**32 - base, 2**63 - 1 - base) this check passes and the caller gets mortie's message instead of the one the comment above promises ("the grammar's own limits are what the caller needs named"). Verified: observation_words with the ICESat-2 epoch and v = (2**63 - 1 - base)/1e9 raises from mortie, not from here. Using 2**63 - 2**32 (or importing the constant if mortie exposes one) restores the intent. test_post_ceiling_time_refused passes either way because 1e18 s is far outside both.

Second, smaller point on the same two lines: base exceeds 2**53, so float(base) / float(ceiling) are only exact when base happens to be a multiple of the local ulp (1024 ns here). An epoch at an odd second — "2018-01-01T00:00:01" — gives base divisible by 512 but not 1024, and the bound then floats by up to 512 ns in whichever direction the cast rounds. Harmless in effect (mortie re-checks the real domain, and a negative internal wraps into the refused band), but the comment claims this check is the domain check, and it is approximate. Comparing in Python ints (int(ns.min()) < -base) costs nothing here since ns is already materialized.

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

Folded in c3f0ab0e. The ceiling now comes from mortie rather than being restated: from mortie.toc import TOC_MAX_NS (2^63 − 2^32), with the maximum allowed offset TOC_MAX_NS - 1 - base, since mortie refuses "at or beyond" it. The refusal message reads 2^63 - 2^32 ns instead of 2^63 ns.

Your second point is folded too: both comparisons are Python ints now, built as int(whole[i]) * 10**9 + int(frac_ns[i]) at the argmin/argmax of the offsets, so float(base)'s up-to-512 ns wobble is gone and the check really is the domain check — the "by N ns" figures in both messages are exact as well. Doing it in ints also keeps the int64 combine unreachable for a grossly out-of-domain offset, which was the reason the check ran in float in the first place. Pinned by test_the_ceiling_is_the_grammars_own_span_not_the_naive_63rd_bit, which pushes an offset into the last 4.29 s band (previously mortie's message, now this one) and checks that the band just below still encodes.

Comment thread src/zagg/processing/spill.py Outdated
cells_with_data``) — one element more than StreamingAggregator, since
spill serves located fields.
spill serves located fields. The §8.3 temporal channel is NOT among them:
``validate_spill_fold`` refuses a ``temporal:`` field, so the channel

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)

nit — this describes the spill path as not carrying the temporal channel, but the single-block regime does carry it, correctly. validate_spill_fold is a probe here, not a refusal:

try:
    validate_spill_fold(config)
    self._mergeable = True
except ValueError as e:
    self._mergeable = False
    self._fold_problems = str(e)

A temporal: config therefore constructs a SpillAggregator with _mergeable = False, and chunk_outputs routes to _chunk_outputs_exact_aggregate_chunk_cells, which emits the full {field: {"locations": [...], "times": [...]}} mapping. The refusal only lands when a block actually closes (SpillOverflowError, carrying _fold_problems) — loudly, so nothing is silently lost; I checked this specifically because a non-mergeable config skipping the fold gate is the failure I most expected here, and it isn't one.

So the accurate statement is stronger than the one written: single-block spill serves both channels exactly; a block close refuses, because the cross-block channel state is located-only. Worth fixing here and in the _chunk_outputs_merged comment at line 1093 ("temporal: never reaches here (refused by validate_spill_fold)") — true of that method, but for the reason that it only runs when _mergeable, not because the config was rejected.

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

Folded in fb9217e3. The SpillAggregator docstring now describes the mechanism: validate_spill_fold is a probe here, a temporal: config lands _mergeable = False and routes through _aggregate_chunk_cells, so single-block spill carries times beside locations exactly; what refuses is a block close (SpillOverflowError, carrying _fold_problems) — loudly — because the cross-block channel state is located-only. The _chunk_outputs_merged comment is corrected the same way: temporal: never reaches there because that method only runs when _mergeable, not because the config was rejected. streaming.validate_spill_fold's own docstring already frames itself as a mergeability probe, so it stands as written.

Comment thread src/zagg/config.py Outdated
from zagg.time_axis import TOC_WORD_COLUMN, toc_source

if toc_source(config) is not None:
if TOC_WORD_COLUMN in ds_vars:

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)

nit — the reservation covers data_source.variables and the segment-level names (both are in ds_vars) and chunk_precompute (line 1723), but not data_source.coordinates. Verified accepted:

cfg.data_source["coordinates"] = {"toc_word": "{group}/toc_word"}
validate_config(cfg)   # passes with output.time_source declared

Coordinate columns are read into col_arrays alongside the variables, so they reach cell_data, where {**cell_data, TOC_WORD_COLUMN: ...} overwrites the read column with the derived words — silently, and only for the cells that have observations. Same corruption shape the ds_vars branch above exists to prevent, one dict away. Folding set((config.data_source or {}).get("coordinates") or {}) into the membership test (with the existing message) closes it.

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

Folded in 41cc48c2: data_source.coordinates is now in the membership test, and the message names the surface the name was found on (data_source.coordinates declares 'toc_word' ...). Your repro raises. Pinned by test_declared_coordinate_may_not_shadow_the_derived_name, with the overwrite-in-cell_data reason in the comment.

@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Blocked on one ruling: how the per-cell toc companion is bound at overview levels

Phases 1–3 have landed the kernel channel, the ingest path, and ruling 4 (located fields fold through the pyramid). Phase 3 stopped exactly at ruling 3"per-cell toc range at overview levels, even where leaves are per-centroid" — because implementing it needs a decision I should not make unilaterally. Two coupled questions, both with concrete options.

Q1 — What binds an overview's per-cell toc array to its field?

At a leaf, §8.3 is unambiguous: the payload array carries a spec-owned times key naming the ragged sibling, and "A reader MUST bind the sibling by that declaration, never by reconstructing a naming convention." At an overview, ruling 3 makes the companion per-cell — a dense uint64 array on the cells axis (§8.2), which is a different kind of array. §8.2 defines no binding at all: it says the array is index-aligned with morton and self-declaring, full stop. §8.3's closing "Per-level shapes need not match" clause licenses the mixed product but does not say how the coarser array is reached.

So there are three implementable readings, and the store bytes differ:

  • (a) Keep the times binding, point it at the dense array. The overview payload carries "times": "h_tdigest_times"; that array is dense uint64, fill_value: 0, declaring shape: "per-cell". A reader binds by declaration exactly as at a leaf and learns the shape from the array it binds — which reads to me as what §8.3's "each array is read through its own declaration" intends. Cost: a config surface that does not exist. kind: ragged + temporal: per-cell is refused today by name ("per-cell is a dense per-cell companion, so kind must be 'scalar'"), and HealpixGrid/RectilinearGrid only emit a {field}_times array when the shape is per-centroid (as a ragged sibling). Both would need a per-level relaxation. The overview config is machine-built and never validated, so the relaxation is only needed for the template path — but it is still new surface.
  • (b) Standalone dense array, no binding. The overview declares {field}_times as an ordinary dense uint64 variable with temporal: per-cell. Zero new machinery — it rides the existing scalar path, and _overview_config just gains a second variable. Cost: the field↔companion relationship is discoverable only by the name convention, which is precisely what §8.3's binding MUST exists to prevent. Legal under §8.2 read literally; against §8.3's spirit.
  • (c) Keep per-centroid at overviews too. No new machinery at all — the temporal channel threads through merge_tdigests_kway(temporal=) exactly as located now does, and every level is §8.3. Cost: contradicts ruling 3 directly, and pays the full per-centroid byte cost at every pyramid level (measured at +2.0–2.1 B/centroid stored, +50% on a located field — see the P0 comment above) for a summary that is lossy anyway. Ruling 3's reasoning — "envelope-of-envelopes is trivially foldable and is the honest temporal statement for a lossy summary" — argues against it.

My read: (a). It is the only one that keeps a reader binding by declaration at every level, the relaxation is confined to the writer-internal template path, and it is what §8.3's per-level clause seems written for. But it does add config surface, so it is a call rather than a detail — and if you prefer (b) I would want a sentence in §8.2 saying a per-cell companion may be reached by convention, so the asymmetry with §8.3 is recorded rather than inferred.

Q2 — The fixture regeneration, and the moczarr window it opens

Whichever way Q1 goes, reclassifying temporal out of class none moves committed §7 bytes. Grounded, because I checked it rather than assuming:

  • Phase 3 (located only) moved nothing: git status tests/data/ is empty on 1639c72c. The only fixture field that is both located and folds under _TDIGEST_FUNCTIONS is temporal/h_tdigest, and its temporal: declaration still forces none; kitchen_sink's located strata fields use build_tdigest_where, which has no _TDIGEST_FUNCTIONS law. So ruling 4 landed fixture-neutral.
  • Reclassifying temporal changes tests/data/spec/temporal/morton_hive.json (h_tdigest and observed move off class: "none"), adds arrays to that fixture's §4.6 leaf column (docs/specification.md: "exact and approximate fields appear, none fields are absent"), gains §5 content-hash keys (the key set is discovery-based over "every named zarr array beneath the leaf root"), and therefore moves FROZEN_COMBINED["temporal"] — and FROZEN_COMBINED_COLUMN if the column leaf changes. It also invalidates test_companions_are_absent_from_the_leaf_column, which exists precisely to pin the current state until this PR flips it.

§7 records that "moczarr vendors the same fixtures for its parity gates (espg/moczarr#19/#20)" with espg/moczarr#23 as the divergence detector, and that regenerated fixtures need a coordinated moczarr bump in the same window. That is a cross-repo, side-effecting decision, so I am not regenerating anything on my own initiative.

What I would do on a green light

  1. Ruling 3 per Q1's answer, wired through the same fold sites phase 3 just opened — the per-cell reduction is already implemented and tested (zagg.stats.toc.cell_envelopes, spec §8.4's licensed shape coarsening, with test_cell_envelope_is_fold_tree_independent pinning the property that lets a cascaded overview and a leaf-folded one agree byte for byte).
  2. Regenerate temporal/ (and column/ if its leaf moves), re-pin the FROZEN_* literals, and post the before/after byte diff on this PR so the moczarr bump has something exact to follow.
  3. Then template wiring — ATL03 delta_time → per-centroid, GEDI per-cell — which is mechanical once the above is settled. One question rides along there: GEDI's delta_time timescale. ICESat-2's is documented GPS-continuous seconds since the ATLAS SDP epoch, which is what output.time_source: {scale: gps, epoch: "2018-01-01T00:00:00"} encodes. GEDI L1B geolocation/delta_time shares the 2018 epoch, but I have not found an authoritative statement that it is GPS-continuous rather than nominal-UTC. Since a utc-scale column is refused by name (§8.3's nanosecond-exactness MUST), that fact decides whether GEDI can carry a companion at all. Confirmable from a real granule against master_time_epoch/master_frac_offset — worth one fleet-side check before the GEDI template ships.

Phases 1–3 stand on their own and are complete, folded and green regardless of how this goes.

@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest",
"status": "404"
}

Comment thread src/zagg/sweep_overview.py Outdated
return encode_digest(merge_tdigests_kway(digests, delta=int(delta)), dtype)
return (
encode_digest(digests[0], dtype),
*(encode_digest(v[0], "uint64") for v in kept.values()),

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)

blocking-adjacent / should-fixfold_digests returns its channel slots in two different orders depending on which arm runs, and a mismatch is a silent morton↔toc swap.

The merge arm delegates the ordering to the kernel, which fixes it by signature:

payload, *words = merge_tdigests_kway(digests, delta=int(delta), **kept)

merge_tdigests_kway builds declared from its own literal (("locations", …), ("temporal", …)) tuple, so its return is always (digest, locations, temporal) regardless of kwargs order. But the other two arms iterate the caller's dict:

if not keep:
    return (b"", *(b"" for _ in channels))
...
if len(digests) == 1:
    return (
        encode_digest(digests[0], dtype),
        *(encode_digest(v[0], "uint64") for v in kept.values()),
    )

Probed on this HEAD:

merge arm  slot0 identical across dict orders: True
merge arm  slot1 identical across dict orders: True
single arm slot0 identical across dict orders: False
  -> single-arm reversed slot0 == the LOCATION words? False
  -> single-arm reversed slot0 == the TOC words?      True

So a caller that builds channels={"temporal": …, "locations": …} gets the toc vector in the locations slot on the single-contributor arm — and every caller zips the result onto field_companions(...) order, so those words land in {field}_locations. Nothing raises: I confirmed the two grammars are mutually accepting (mortie.validate_morton accepts toc words, mortie.toc_is_range accepts morton words), so the swap survives every downstream check and the store's containment claims are simply false.

No live caller violates it today — all five sites build the dict from field_companions, which walks COMPANION_CHANNELS. But the function's own docstring says it length-checks "HERE rather than at the call sites, because this function is the documented seam they must come from together", and it names the single-contributor arm as "the majority case at the finest overview level, where an output cell usually has exactly one populated source". The one precondition whose violation is silent corruption is the one left unenforced. Normalizing through the table makes all three arms agree unconditionally, e.g.

kept = {kw: [channels[kw][i] for i in keep] for _k, kw, _n in COMPANION_CHANNELS if kw in channels}

(with the empty arm iterating the same normalized keys), which also removes the zip(declared, words, strict=True) assumption from all four call sites.

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 in db4ebb1. All three arms now normalize through COMPANION_CHANNELS, so the return order is unconditional:

declared = [kwarg for _key, kwarg, _namer in COMPANION_CHANNELS if kwarg in channels]
unknown = sorted(set(channels) - set(declared))
if unknown:
    raise ValueError(...)
...
if not keep:
    return (b"", *(b"" for _ in declared))
kept = {kwarg: [channels[kwarg][i] for i in keep] for kwarg in declared}
if len(digests) == 1:
    return (
        encode_digest(digests[0], dtype),
        *(encode_digest(kept[kwarg][0], "uint64") for kwarg in declared),
    )

Three notes on the shape of the fix:

  • The length-check loop iterates declared too, so validation and emission walk the same list.
  • I added the unknown refusal deliberately. Normalizing through the table silently DROPS a key that is not in it, which would return fewer slots than the caller zips — a strict=True blowup at best, an off-by-one channel assignment at worst. Rejecting is the honest seam.
  • The docstring now states the ordering guarantee and why (the k-way merge fixes its own order by the same table; the grammars are mutually accepting so a swap raises nowhere).

I kept zip(declared, words, strict=True) at the four call sites. It is no longer an assumptionfield_companions walks the same table, so the pairing is guaranteed by construction — and it is still the thing that maps a returned word to its sibling ARRAY NAME, which the call sites need regardless. Dropping the strict=True would only remove a free assertion.

Tests in 7e0a6a0 (see the reply on the coverage finding). Two go straight at this: TestOverviewFoldDigests::test_slot_order_is_the_table_not_the_callers_dict parametrized over 0/1/3 contributors — all three arms, forward and reversed dict order, asserted byte-identical — and test_an_unknown_channel_is_refused_not_dropped. Plus four end-to-end tests that fail under the swap: I verified by mutating declared to [::-1] on this HEAD, which reproduced exactly the bug you describe (the merge arm is unaffected, the single arm swaps) and took down TestBothChannelsOverviewFold::test_singleton_centroids_round_trip_both_words_unswapped[single-arm], ::test_merged_centroids_contain_their_members_in_both_grammars, ::test_cascade_folds_both_siblings and TestBothChannelsColumn::test_the_fold_returns_both_siblings_against_their_own_oracles.

Comment thread src/zagg/sweep_stage.py
for _kwarg, sibling in declared:
arrays[sibling] = reader.read(res, sibling)
present = [k for k, v in arrays.items() if v is not None]
if not present or len(present) == len(arrays):

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)

should-fix (test strength) — the whole point of this phase is N channels on one field, and no test in the suite ever puts two channels on one field through any sweep_overview or sweep_stage fold path. Every fold-path test is location:-only:

  • tests/test_sweep_overview.pyLOCATED_FIELDS_DECL sets only "location": "leaf_id"; TestLocatedOverviewFold / TestLocatedDeclarationGate never declare temporal.
  • tests/test_sweep_stage.pyLOCATED_FIELDS / TWO_LOCATED_FIELDS likewise. Note TWO_LOCATED_FIELDS is two located fields (h_tdigest, h2_tdigest), which pins _close's per-field rebinding, not two channels on one field.
  • tests/test_column.pyLOCATED_FIELDS is location: only.
  • grep -rn 'channels=' tests/ returns exactly three hits, all {"locations": …} single-key.

So _companion_group's new partial rule is never exercised at its interesting arity. With one channel arrays has 2 keys and the rule degenerates to the old (payload is None) == (words is None); the branch this phase actually added is len(present) == 2 out of 3 — payload + one sibling present, the other absent — and nothing reaches it. test_a_gather_source_without_its_channel_is_not_written_half and test_a_merge_source_without_its_channel_is_skipped_not_raised drop the only sibling there is, i.e. they still test the 1-of-2 shape.

I wrote the probes rather than assume: all of it works, so this is coverage, not a bug. For the record, on this HEAD with {"location": "leaf_id", "temporal": "per-centroid"} on h_tdigest:

# _fold_node / pyramid sweep (harness = test_sweep_overview._make_located_leaf + a times sibling)
failed: 0
payload rows (4, 2) loc (4,) times (4,)
loc anc match: True          # common_ancestor(col) == common_ancestor(leaf words)
time envelope match: True    # cell_envelope(col) == cell_envelope(leaf words)

# _gather_slabs / _merge_slabs ladder (harness = test_sweep_stage._stage_store)
ladder: 7 written, 0 failed
gather level rows: 2 aligned    merge level rows: 1 aligned

# the partial-group branch: rmtree(3/h_tdigest_times), locations left in place
partial ladder: 7 written, 0 failed
gather counts: {'folded': 1, 'missing': 0, 'unreadable': 1}
payload[0] empty: True

Parametrizing TestLocatedStageSweep and TestLocatedOverviewFold over a BOTH_FIELDS declaration is ~40 lines against harnesses that already exist, and it is what would catch a channel dropped, mis-paired, or folded separately at these five sites — which is the exact failure mode the module docstrings cite as "silent corruption". Related: test_temporal_per_centroid_is_approximate also carries "location": "leaf_id", so no test pins the temporal-only per-centroid shape (which is what a build_tdigest field with a clock and no location: is).

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

Covered in 7e0a6a0 — a both-channel field now folds end to end at all five sites, and both siblings are asserted against their own grammar.

tests/test_sweep_overview.pyBOTH_CHANNEL_FIELDS_DECL and TIMED_FIELDS_DECL; _located_leaf_cfg / _make_located_leaf take location= / temporal= and build both siblings in ONE build_tdigest call. New TestBothChannelsOverviewFold:

  • test_both_siblings_are_emitted_row_aligned_and_declared — both arrays present, both row-aligned with the payload, each declaring its own convention (zagg-located/1 vs zagg-toc/1), and the payload binding both by name (ragged.locations + the top-level times attr).
  • test_singleton_centroids_round_trip_both_words_unswapped[single-arm|merge-arm] — parametrized so one populated leaf row takes fold_digests' single-contributor arm and two take the merge arm. Every centroid is a singleton at δ=64, so BOTH channels must reproduce their contributor's exact word: assert_array_equal against the leaf truth, per channel. This is the decisive anti-swap pin.
  • test_merged_centroids_contain_their_members_in_both_grammars — 400 obs, real merges; per centroid, common_ancestor containment (§9.1) AND toc coverage (§8.3) against the same weight-derived partition, plus the negative (_toc_contains(locs[0], ...) is false).
  • test_cascade_folds_both_siblings — the fold-of-folds path, checked at cell granularity (cell_envelope identity and the common_ancestor analogue).
  • test_temporal_alone_folds_through_the_pyramid — your last point: a build_tdigest field with a clock and no location:, which nothing pinned.

tests/test_sweep_stage.pyBOTH_CHANNEL_FIELDS (one field, two channels — distinct from TWO_LOCATED_FIELDS) and TIMED_FIELDS; _located_leaf_slabs generalized to write whichever siblings a field declares. New TestBothChannelsStageSweep: the ladder row-aligned at a gather and a merge level; the gather relaying each channel into its own slot byte-for-byte; the merge satisfying both containment claims with the cross-check that the located bytes do NOT satisfy the temporal one; test_one_channel_missing_refuses_the_whole_group_companion_group's new branch at len(present) == 2 of 3, which is what you flagged as unreachable: it asserts the payload AND the intact sibling both stay fill and source_children == {folded: 1, missing: 0, unreadable: 1}; and the timed-only ladder.

tests/test_column.pyBOTH_CHANNEL_FIELDS, _located_cell_slabs(cells, fields), TestBothChannelsColumn: both siblings byte-equal to the corresponding output of the SAME merge_tdigests_kway call on the pooled row, plus the single-contributor row passing each channel's own bytes through, leaf_slabs picking up both, and the by-name refusal for a missing _times slab.

Mutation-checked rather than assumed: with declared reversed in fold_digests (the exact bug in the other finding), four of these fail — three in TestBothChannelsOverviewFold and TestBothChannelsColumn::test_the_fold_returns_both_siblings_against_their_own_oracles. Full suite green apart from the pre-existing test_lambda_build::TestFunctionBuild::test_function_build_succeeds; no committed fixture byte moved (git status tests/data/ empty).

words = np.frombuffer(bytes(arr[:][i]), "<u8")
assert words.shape == (rows,), f"{group}/{name} row {i}"
# §8.2's reserved 0 never appears in a real word.
assert words.all()

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)

should-fix (test strength) — this is the only golden for a merge-produced companion (the §7 paragraph added in this phase says exactly that: "the fixture set's only golden for a companion produced by a merge rather than by ingest"), but its assertions cannot tell a correct fold from a swapped or wrongly-folded one.

The two content assertions are words.shape == (rows,) and words.all(). Both hold under a straight swap of the two committed sibling chunks — same length, same nonzero-ness — and the declaration checks above are on attrs, which don't move when the bytes do. I checked whether the grammars discriminate; they don't:

4 row 0: rows=19
   validate_morton(loc)=accepts   validate_morton(times)=accepts
   toc_is_range(loc)=accepts      toc_is_range(times)=accepts
   times decode        -> 2019-05-14T02:11:05.877 .. 2019-05-14T02:40:18.223
   LOC-as-times decode -> 1869-07-21T02:59:08.949 .. 1869-08-15T18:42:44.391

A morton word decodes as a perfectly well-formed (1869-era) toc word and vice versa, so no validator in the stack raises. Compare the leaf, which is pinned two independent ways — test_words_conservatively_contain_their_members (containment against obs_span_ns) and test_the_production_kernel_reproduces_the_committed_words (word-for-word against the reducer). The folded column gets neither.

The committed bytes are right — I verified independently, and this is the check that belongs in the test:

res 5: 4 cells, factor 4
  cell 0: envelope leaf==col: True   loc common_ancestor leaf==col: True
  cell 1: envelope leaf==col: True   loc common_ancestor leaf==col: True
  cell 2: fill
  cell 3: envelope leaf==col: True   loc common_ancestor leaf==col: True
res 4: 1 cells, factor 16
  cell 0: envelope leaf==col: True   loc common_ancestor leaf==col: True

i.e. int(cell_envelope(column_times[j])) == int(cell_envelope(concat(leaf_times[j*f:(j+1)*f]))) — the §8.3 cell-level identity _centroid_envelopes' docstring names as the invariant that survives an arbitrary fold tree — and the mortie.common_ancestor analogue for the located slot. Two lines per resolution, and they make the channel identity load-bearing.

(The rest of the fixture regeneration checks out: git diff 351741fb...HEAD -- tests/data/spec/temporal/ touches no leaf array byte — only 11213.zarr/zarr.json's written_at and morton_hive.jsonFROZEN_COMBINED/FROZEN_ARRAYS/FROZEN_COMBINED_COLUMN were last edited in f1fbb5e6 (phase 3) so nothing was re-pinned, the committed semantic_hash is unchanged, and re-running tools/generate_spec_fixtures.py --only temporal reproduces all six new column chunks byte-identically.)

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

Added in 0f153a9TestTemporalCompanions::test_the_columns_words_reproduce_the_leafs_cell_by_cell. It is exactly the check you verified: per column cell, per channel, reduce the column's words and the leaf words under them and compare.

reducers = {
    "h_tdigest_locations": lambda w: int(common_ancestor(w)),
    "h_tdigest_times": lambda w: int(cell_envelope(w)),
}
for group in sorted(...):
    factor = 4 ** (leaf_order - int(group))
    ...
    assert reduce(got) == reduce(members), f"{group}/{name} cell {j}"

with members = concat(leaf[name][j * factor : (j + 1) * factor]), an unpopulated column cell asserted to have had no members (so an empty row cannot hide a dropped fold), and a checked counter so the loop cannot pass vacuously. It runs over both committed groups (5 at factor 4, 4 at factor 16) and both channels — 10 populated (cell, channel) pairs.

You are right that this closes the ordering gap from the fixture side: the two reducers are channel-specific, so a swapped pair fails. I checked that rather than assuming it — pointing got at the other channel's chunk while keeping members and the reducer makes the test fail. It is the only golden for a merge-produced companion, so it is worth it being load-bearing for moczarr.

No fixture byte moved (git status tests/data/ empty) — this is a test-side addition against the bytes already committed.

Comment thread src/zagg/stats/waveform.py Outdated
the value co-sort carry along so each output centroid gets the envelope of
the samples that survived into it
(:func:`zagg.stats.tdigest._centroid_envelopes`). Given, the return is a
``(digest, words)`` pair. Per-centroid at every level, symmetric with the

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)

nit — "Per-centroid at every level" reads as a statement about this reducer's stores, and it can't be: build_waveform_digest is not in zagg.processing.streaming._TDIGEST_FUNCTIONS, which is the allowlist semantics.field_composability's ragged branch tests, so a waveform field is composability class none and never appears above native resolution at all.

>>> field_composability({"kind": "ragged", "function": "zagg.stats.waveform.build_waveform_digest",
...                      "inner_shape": [2], "temporal": "per-centroid", "dtype": "float32"})
'none'
>>> field_composability({**_, "function": "zagg.stats.tdigest.build_tdigest"})
'approximate'

That is consistent with the GEDI template's pyramid: false, and it is presumably deliberate — but as written the sentence invites a reader to expect a rx_flux_times sibling on a GEDI overview, and there will never be one. Suggest scoping it: the ruling is per-centroid at every level; this reducer emits the leaf's words, and a waveform store has no overview levels to carry them. (The honesty property below — one shot ⇒ exact timestamps — is well grounded and pinned by TestGediTemplate::test_template_reads_and_aggregates_the_fixture, so only the "at every level" clause is at issue.)

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

Corrected in cf699e0. The "at every level" clause is scoped to what is true for this reducer:

The per-centroid shape is the one the espg ruling of 2026-08-17 makes universal, identical to the located channel's; the ruling's at every level half does not describe this reducer's stores, because build_waveform_digest is absent from zagg.processing.streaming._TDIGEST_FUNCTIONS — so a waveform field is D24 class none (issue #422, and the GEDI template's pyramid: false) and exists at native resolution only. There is no waveform overview to carry a companion, and a reader must not expect one.

The honesty property is unchanged, moved to its own paragraph. _TDIGEST_FUNCTIONS left alone per your note.

Also pinned the classification so the corrected sentence cannot drift back: TestFieldComposability::test_a_temporal_waveform_field_is_still_none asserts field_composability is none for a build_waveform_digest field carrying temporal: per-centroid, and approximate for the same declaration under build_tdigest — i.e. the class turns on the function, not on the channel.

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

Correction to my reply just above: the new test is TestComposabilityClasses::test_a_temporal_waveform_field_is_still_none, not TestFieldComposability — I named the class from memory. Same test, same assertions, in cf699e0 alongside test_temporal_per_cell_stays_none.

# the nanosecond while a centroid is one photon. Per-centroid at EVERY
# pyramid level, symmetric with the located channel (espg-ruled
# 2026-08-17). Stored as the {field}_times sibling, bound from the
# payload's `times` attr; read it back with zagg.readers.

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)

nit — "read it back with zagg.readers" overstates what the reader package offers for this channel. zagg.readers exports read_locations, which binds the §9 sibling from attrs["ragged"]["locations"]; there is no counterpart that binds the §8.3 times key, and read_locations raises by name for a field that "declares no locations channel". The only route today is read_raw_values(store, "h_tdigest_times", cell) — which does work, since the sibling is an ordinary ragged uint64 array — but a reader following this comment will look for a times helper and not find one.

Relatedly, src/zagg/readers/tdigest_tensor.py's on-disk layout header still documents only the located sibling:

    {group}/{field}_locations  <- located fields only (issue #87): the uint64
...
                       "locations": "<sibling name>"}   # located fields only

so the reader module's own description of the layout is now incomplete for the stores the two shipped templates write. Either point the comment at read_raw_values explicitly, or note the sibling in the reader header — whichever, so the yaml and the reader agree.

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

Corrected in 8cc8bb5 — and one correction to the finding: read_raw_values does not work on the sibling either. I tried it against the committed §7 fixture before writing the comment:

>>> read_raw_values(LocalStore(leaf), "6/h_tdigest_times")
  File "src/zagg/readers/tdigest_tensor.py", line 982, in read_raw_values
    weights = np.asarray(digest[:, 1], dtype=np.float64)
IndexError: too many indices for array: array is 1-dimensional, but 2 were indexed

It decodes a (n, 2) digest and gates on centroid weights, so a flat (n,) word vector has no route through it. So there is no reader path for this channel at all today, and the comment now says that instead of naming a substitute:

NOTE zagg.readers has no helper for this channel yet: read_locations binds the section 9 ragged.locations key only, and read_raw_values decodes a (n, 2) digest, not a flat word vector. Until one exists, decode the sibling directly -- it is an ordinary ragged uint64 vlen array whose element dtype/shape its own ragged attrs declare -- and interpret the words with mortie (toc_is_range, toc2time).

And the reader module's layout header now documents the sibling and the gap: {group}/{field}_times added to the layout block, attrs["times"] = "<sibling name>" added to the attrs block, plus a paragraph recording that no helper here binds the channel yet and why neither existing function substitutes.

Left standing for @espg (per the fold rules — a new public reader is scope, not a fold): I do think a read_times is wanted, and the natural shape is a channel= parameter on read_locations rather than a third near-duplicate sweep, since the two differ only in which attrs key binds the sibling and how the words are interpreted. Both shipped templates now write the sibling, so a client following the §8.3 docs has nothing to call.

@espg
espg marked this pull request as ready for review August 17, 2026 16:31
@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Ready for review — all phases complete, CI green

Head 8cc8bb50. Marked ready and labelled waiting: the ball is in your court, and one item stands (the reader-binding gap, last section).

Phase 4 under the (c) ruling

Ruled option (c) — companions per-centroid at every level, symmetric with located, §8.4's reduction licensed-but-unused. What it bought, beyond matching the ruling: it made the code smaller than the alternative. No second shape to produce, no per-cell overview array, and the binding question I was blocked on simply vanished — there is nothing to bind, so §8.2's missing binding grammar never had to be closed.

The plumbing was generalized rather than duplicated. Phase 3 had threaded one channel through five fold sites with a bespoke pair at each; adding a second by copy would have doubled that. Instead fold_digests(..., locations=) became channels={kwarg: vectors}, driven by a single COMPANION_CHANNELS table plus field_companions(name, meta), and every site — _fold_node, _cascade_node/_fold_child, sweep_stage._gather_slabs/_merge_slabs, column.leaf_slabs/fold_column, _field_drift, _overview_config — walks that one table. check_located_match became check_companion_match, whose temporal arm additionally pins the shape to per-centroid, because a per-cell block reaching a per-centroid fold would decode one word per cell as a vector and produce envelopes whose containment claim is false.

A temporal: per-cell dense field stays class none — the one sub-decision I made rather than read off the ruling, and flagged as such. Its fold law is the grammar's join over a cell group, not its own reducer, so classifying it by function would fold nanmax over toc words. The §8.2 shape still works at a leaf (GEDI's observed-style companion; the fixture still commits it); only its pyramid behaviour is unwired, which no shipped config needs.

Fixture regeneration

tests/data/spec/temporal/ regenerated through the production path. Determinism checked: re-running the generator reproduces all six new column chunks byte-identically.

leaf arrays (11213.zarr/6/*) byte-identical — only the commit stamp's written_at
all.pyramid.zarr (§4.6 column) gains h_tdigest + both siblings at resolutions 4 and 5
all.pyramid.stats.json six new per-array hashes; combinedb6859b89…
morton_hive.json h_tdigest noneapproximate (+ location, temporal, δ); observed stays none
FROZEN_COMBINED / FROZEN_ARRAYS / FROZEN_COMBINED_COLUMN / golden fb15224f… unchanged, not re-pinned — the leaf is what they cover

That also closed a standing question for free: this is the fixture set's first golden for a companion produced by a merge rather than by ingest, now pinned by value — per column cell, cell_envelope identity and the common_ancestor hull against the leaf cells beneath it.

Cross-repo, not executed here: moczarr re-vendors this fixture after merge (espg/moczarr#19/#20, detector #23) — the #43 re-check is the natural vehicle. Different repo, so flagged rather than actioned.

The GEDI companion is GPS-scale, verified

BEAM0000/ancillary/master_time_epoch reads exactly 1,198,800,018.000000 on two independent real granules — the 2018-01-01 epoch in leap-aware GPS seconds (naive UTC would be 1,198,800,000; the +18 is GPS−UTC at 2018). So GEDI shares ICESat-2's convention and scale: gps is exact offset arithmetic, no leap table in the hot path. A nominal-UTC column would have been refused by name (§8.3's nanosecond MUST), so that constant is what admits the declaration at all — cited in the template and pinned by a test.

Ruling 2's honesty property survives, expressed per centroid: all of a shot's samples share its instant, so a single-shot cell's centroids are exact timestamps and only genuinely pooled cells produce ranges. _validate_time_source was widened to accept a broadcast level variable as base-rate, which is what admits a shot-rate clock — base-rate means one value per observation after the read.

All six questions resolved

  1. per-cell binding fork — ruled (c); question dissolved.
  2. pre-2017 tai epoch splits conversions by 19 s — issue windows.py: pre-2017 tai-labelled epoch conventions split word/router conversions by 19 s #469. No shipped config affected.
  3. _merge_slabs skips the companion gate — issue sweep_stage._merge_slabs bypasses check_weights_match and check_located_match (attrs never surfaced) #470 (pre-existing for §2.0 too).
  4. no §7 golden for a folded companion — closed by the regeneration above.
  5. time_source in the D19 core — blessed as landed (cb5d0648).
  6. GEDI timescale — verified, above.

The one standing item

No reader binds the temporal channel. readers.read_locations binds the ragged block's locations key only, and read_raw_values is not a substitute — it indexes digest[:, 1] and raises IndexError on a flat word vector (verified against the committed fixture). So the channel is written, declared, folded and conformance-pinned, but a zagg consumer must open the sibling by hand. External readers are unaffected (they decode from the spec + fixtures, and both are complete).

The shape of the fix is a channel= parameter on read_locations, not a third near-duplicate sweep of the same ragged-open/decode path. Left unimplemented because it is new public API, and a fold is the wrong place to land that. The yaml comment and readers/tdigest_tensor.py's layout header were corrected to state the gap rather than promise a reader that does not exist.

State

Four phases plus the P0 measurement, 41 commits. 28 review findings across four adversarial passes, 27 folded, 1 declined (a new §7 fixture for a located pyramid — it needed the same cross-repo decision, so doing it inside a fold would have landed that by side effect); every thread has a reply. Three syncs onto main (#420 + reconciliation, #455/#461/#462, #466), no textual conflicts.

Local: 4,433 passed, 38 skipped, 1 failedtest_lambda_build::TestFunctionBuild::test_function_build_succeeds, pre-existing and environmental (needs the container build toolchain), flagged not fixed per CLAUDE.md §4. CI on 8cc8bb50: all eight check-runs green. Three of them went red first on GitHub 429/503 rate limits at "Set up job" — before reaching any code — and passed on re-run; test (3.12) passing on the same commit throughout corroborated that reading.

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.

1 participant