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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/zagg/configs/gedi01b_waveform_healpix_hive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@ output:
sharded: true
child_order: 18 # leaf cell resolution (~20 m, ~GEDI footprint)
store_layout: hive
pyramid: false # composability none initially (issue #422)
pyramid: false # opt-in (runbook: CA builds pyramids-off on
# 0.49); rx_flux classifies approximate since
# issue #508, so pyramid: {} builds the ladder

@espg espg Aug 24, 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)

Verified: taking this comment's advice today publishes a flux ladder that declares itself counts and carries none of the leaf's calibration provenance.

Emitting the leaf template and the overview template side by side for this config (_overview_config(composable_fields(declared_fields(cfg)[0])), output.pyramid = {}):

leaf     rx_flux: {"clip": "threshold-to-zero", "gain": 1.0, "gain_name": "unit",
                   "gain_version": "gedi01b-v002-placeholder",
                   "operating_point": {...}, "ragged": {...}, "times": "rx_flux_times"}
overview rx_flux: {"ragged": {...}, "times": "rx_flux_times"}

and the overview root records only {"class": "approximate", "method": "tdigest_kway"} (sweep_overview._field_provenance). _overview_config carries gain through only when meta["weights"] not in (None, "counts") (pyramid.py:222-226sweep_overview.py:2053-2056), and this template's weights: flux is still commented out pending #431 (lines 187-188 above), so nothing is carried.

Two things to decide before the ladder is advertised here:

  1. Every array the flip creates — overview levels and the leaf column groups — is an absent-weights payload. Spec §2.0 says an absent key MUST be read as counts, and §2.1 then requires integer weights ≥ 1 whose sum is the cell's exact observation count. rx_flux weights are (count − noise_mean)·gain floats. The leaf already carries that mismatch (pre-existing, GEDI waveforms 1/3: §2 counts/flux weights declaration + δ=8,192 raise #431's job), but D24 composability: build_waveform_digest not admitted as approximate — GEDI pyramid declares class none with pyramid on #508 is what replicates it across every level of a ladder, and this comment is what invites an operator to turn it on.
  2. When GEDI waveforms 1/3: §2 counts/flux weights declaration + δ=8,192 raise #431 uncomments weights: flux, the carry-through that does exist propagates a bare attrs: {"gain": 1.0} — no name/version in any spelling, which §2.0 makes REQUIRED beside a flux declaration ("a gain key carrying at minimum the gain constant's name and version"), and gain_name/gain_version/clip/operating_point still never reach an overview. So the flux ladder is not self-describing even after the rebase this comment points at.

Cheapest fix consistent with the phase scope: sequence the "so pyramid: {} builds the ladder" half of this comment behind #431 (or behind a carry-through of the field's declared attrs), rather than asserting it now.

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 as a durable record in the template, 69cf3c7 — not as a src change, because the leaf and the overview agree here and the divergence is #431's.

What changed. The # weights: flux pending-rebase comment now says why it is load-bearing for the pyramid and not just the leaf: zagg.pyramid.declared_fields keys weights (and the gain beside it) into the manifest entry only when it is set, and sweep_overview._overview_config carries that pair into every overview level's template on the same condition — so commented out, leaf and overview agree by ABSENCE, which spec §2.0 makes readers take as counts while these weights are (count − noise_mean)·gain floats. It ends with the operator instruction: enable before building a GEDI ladder anyone will analyze. The pyramid: false comment this thread is anchored to now points at that note, so the "pyramid: {} builds the ladder" invitation does not read as unqualified.

Point (2) verified, and standing for #431. Simulating the rebase (weights: flux on the shipped template, pyramid = {}) the manifest entry is {..., "weights": "flux", "gain": 1.0} and the overview template is {..., "weights": "flux", "attrs": {"gain": 1.0}} — a bare scalar. No name/version in any spelling, which §2.0 makes REQUIRED beside a flux declaration, and gain_name/gain_version/clip/operating_point never reach an overview at all. So the flux ladder is not self-describing even after the rebase. Recording it here rather than changing src: the carry-through shape is pyramid.py:222-226 / sweep_overview.py:2053-2056, which is exactly the code #431 touches when it turns the declaration on, and fixing it here would fold a §2.0 conformance change into a composability PR.

Declined as #431 scope, by design: keying weights unconditionally, or widening the carry-through to the field's declared attrs, or gating the D24 flip behind the weights declaration. Point (1)'s underlying mismatch is pre-existing at the leaf; #508 replicates it across levels only for a store built with pyramid: {} before #431 lands, which the comment now warns against and the CA runbook does not do (pyramids-off on 0.49, ladder retrofit sweep-only).

Gates: ruff check src tests clean except the pre-existing N818 in registry.py; pytest tests/test_read_vlen.py tests/test_config.py tests/test_semantics.py tests/test_stats_toc.py tests/test_spill_crossblock.py 617 passed.


worker:
memory: 4096 # optimized probe (run 5d081b68, 4/4 green):
Expand Down
20 changes: 20 additions & 0 deletions src/zagg/processing/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,26 @@
_TDIGEST_WHERE_FUNCTION = "zagg.stats.tdigest.build_tdigest_where"
_TDIGEST_SPILL_FUNCTIONS = (*_TDIGEST_FUNCTIONS, _TDIGEST_WHERE_FUNCTION)

#: The digest FAMILY (issue #508): reducers whose STORED payloads are spec-§2
#: weights-sorted ``(k, 2)`` centroid arrays and therefore fold by the
#: order-independent k-way t-digest law (``merge_tdigests_kway``) wherever a
#: fold operates on stored payloads rather than raw rows — the D24
#: composability arm (:func:`zagg.semantics.field_composability`) and the
#: overview pyramid it licenses (:func:`zagg.sweep_overview.fold_digests`).
#: The k-way law is weight-agnostic — flux weights fold like counts (spec
#: §2.0, issue #431) — which is what admits ``build_waveform_digest``: its
#: build is waveform-specific, but its stored payload is a standard §2
#: centroid array whose companions ride the same channel overloads.
#: Deliberately NOT consumed by :func:`validate_streaming` /
#: :func:`validate_spill_fold`: those gates re-run BUILDERS over raw rows (per
#: flush / per block), and the waveform builder's noise-model columns are not
#: threaded there — the tuples above keep their exact members (the issue #508
#: phase-1 characterization pins that posture). ``build_tdigest_where`` is
#: likewise NOT here: its D24 class stays ``none`` pending the gate-drift
#: ruling raised on issue #508.
_WAVEFORM_DIGEST_FUNCTION = "zagg.stats.waveform.build_waveform_digest"
_DIGEST_FAMILY_FUNCTIONS = (*_TDIGEST_FUNCTIONS, _WAVEFORM_DIGEST_FUNCTION)

#: The packed composition reducer (issue #321): the SPILL fold collapses its
#: per-block ``(word, n_signal)`` pairs in one pass via
#: ``merge_composition_kway`` — issue #370 option (a), accepting the documented
Expand Down
17 changes: 10 additions & 7 deletions src/zagg/semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,12 @@ def field_composability(meta: dict) -> str:
``validate_streaming`` accepts, widened by the exact sum/min/max laws):

- ``exact`` — scalar ``function`` in :data:`EXACT_MERGE_LAWS`;
- ``approximate`` — a ragged t-digest field with the standard ``(2,)``
centroid inner shape, **located or not** (merge is order-dependent;
``np.isclose`` equality class, cf. D24);
- ``approximate`` — a ragged digest-family field with the standard
``(2,)`` centroid inner shape, **located or not** (merge is
order-dependent; ``np.isclose`` equality class, cf. D24). The family is
:data:`zagg.processing.streaming._DIGEST_FAMILY_FUNCTIONS` — the
t-digest builders plus the waveform flux digest (issue #508), every
reducer whose stored payload folds by the k-way law;
- ``none`` — everything else: expressions, vector fields, chunk-resolution
companions, a ``temporal: per-cell`` dense companion (see below), and any
scalar reducer without an exact law (mean, std, median, quantiles, ...).
Expand Down Expand Up @@ -403,8 +406,8 @@ def field_composability(meta: dict) -> str:
word grammar's join over a cell group, not its own reducer, so classifying
it by ``function`` would fold e.g. ``nanmax`` over toc words and emit a word
whose conservative-envelope claim is false. Wiring a dense toc law is not
what the ruling asked for, and no shipped config needs it (the GEDI template
declares ``pyramid: false``), so it is left out rather than guessed at.
what the ruling asked for, and no shipped config pairs a per-cell
companion with a pyramid, so it is left out rather than guessed at.
"""
from zagg.config import get_output_signature
from zagg.time_axis import TOC_SHAPE_PER_CELL
Expand All @@ -416,9 +419,9 @@ def field_composability(meta: dict) -> str:
return "none"
function = _fold_function_name(meta.get("function"))
if sig["kind"] == "ragged":
from zagg.processing.streaming import _TDIGEST_FUNCTIONS
from zagg.processing.streaming import _DIGEST_FAMILY_FUNCTIONS

if meta.get("function") in _TDIGEST_FUNCTIONS and tuple(sig["inner_shape"]) == (2,):
if meta.get("function") in _DIGEST_FAMILY_FUNCTIONS and tuple(sig["inner_shape"]) == (2,):
return "approximate"
return "none"
if sig["kind"] == "scalar" and function in EXACT_MERGE_LAWS:
Expand Down
16 changes: 9 additions & 7 deletions src/zagg/stats/waveform.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,15 @@ def build_waveform_digest(
the samples that survived into it
(:func:`zagg.stats.tdigest._centroid_envelopes`). Given, the return is a
``(digest, words)`` pair. 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.
ruling of 2026-08-17 makes universal, identical to the located channel's,
and its *at every level* half describes this reducer's stores too:
``build_waveform_digest`` is a member of the shared digest family
(``zagg.processing.streaming._DIGEST_FAMILY_FUNCTIONS``, issue #508), so a
waveform field is D24 class ``approximate`` and folds through the overview
pyramid when one is declared — each overview level carries the
per-centroid companion beside the folded payload. (The build-time gates
are unchanged: the merge/spill folds still refuse the builder, so a

@espg espg Aug 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

The flip does not leave build time alone: it switches on the leaf column fold for rx_flux, and the repo's own measurement says a GEDI-scale digest load does not fit there.

Measured on this commit vs origin/main, gedi01b template with output.pyramid = {}:

# origin/main:  leaf_column_plan(cfg, HealpixGrid(9, 18, config=cfg, sharded=True, chunk_inner=12))
pyramid={} -> ([12, 11, 10, 9], ['count'])
# c1fc01a0
pyramid={} -> ([12, 11, 10, 9], ['count', 'rx_flux'])

column.leaf_column_plan takes composable_fields(declared_fields(config)[0]), so an approximate rx_flux now enters fold_column at the tail of hive.process_and_write_hive — and the node-order member (res 9, factor 4**9) k-way merges every resident digest in the leaf in one merge_tdigests_kway call. That hook is already measured, and GEDI is named as the regime it cannot take:

  • src/zagg/column.py:701-717 — "at the ~17.6M-centroid scale ... the fold measured ~2.0 GB (float64 copies + sort temporaries inside merge_tdigests_kway) ... it is 4 GB workers (issue Skip zarr metadata consolidation by default (opt-in) #193) absorbing ~2.0 GB on top of a loaded heap, not beside an empty one. A digest load ~2x that scale does not fit and needs the kernel-side preallocation named on the PR thread before the column can carry it."
  • src/zagg/hive.py:1621-1625 — the same +2.0 GB transient, "on top of this accumulation, since nothing here is released before that call".
  • This template's own header: a shard reads 50-82M rows and keeps 21-33M (configs/gedi01b_waveform_healpix_hive.yaml:144-152), and a waveform digest is loss-free below δ=8192, so centroids ≲ kept rows — 1.2-1.9x the 17.6M measurement, on a worker whose measured HWM is already 2,472 MB of the 4096 tier and which also writes 1.6-2.5 GB of spill.

Local scaling check (merge_tdigests_kway, δ=512, with a temporal channel): 4M centroids → 2.61 s / 668 MB peak RSS; 8M → 6.18 s / 1,197 MB. ~140 B/centroid, i.e. ~3-4.6 GB transient at 21-33M.

This is exactly the run phase 3 plans (the SERC probe declares pyramid = {} at build, not sweep-only), so it is reachable on the next fleet run rather than hypothetical.

Three ways out, pick one: (1) keep the ladder retrofit sweep-only (declare_pyramid + mode=sweep, which is already the runbook contingency) so waveform leaves write no column; (2) measure the column hook at GEDI scale before phase 3 declares the path green; (3) at minimum, stop this passage claiming build time is unchanged — the gates are unchanged, the work is not. Relatedly, _DIGEST_FAMILY_FUNCTIONS' docstring enumerates the fold sites as the D24 arm plus sweep_overview.fold_digests and omits zagg.column.fold_column, which is the one that runs on the worker.

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 docstrings and pinned the consequence in 1123cd2; the memory question itself is left standing for espg.

Docstrings. _DIGEST_FAMILY_FUNCTIONS now enumerates BOTH stored-payload fold sites — zagg.sweep_overview.fold_digests and zagg.column.fold_column, with the leaf_column_plancomposable_fields route named — and says membership is not free at build time. build_waveform_digest's passage no longer implies the flip is cost-free: it keeps "the build-time GATES are unchanged" but adds that the WORK is not, cites write_leaf_column's own ~2.0 GB / ~17.6M-centroid measurement as the envelope that is unvalidated at GEDI's 21–33M kept rows, and names the ruled deployment path — pyramids-OFF aggregation, then declare_pyramid + a sweep-only overview pass, which builds the ladder without putting the column fold on the aggregating worker.

Pin. New TestWaveformEntersTheColumn in tests/test_column.py, beside the existing leaf_column_plan gate tests: the shipped template (pyramid: false) declares no column, and with output.pyramid = {} the plan is ([12, 11, 10, 9], {"count", "rx_flux"}) with rx_flux class approximate, temporal per-centroid. Pre-#508 the field set was {"count"}, so a future removal of the family membership is a loud diff.

Not folded, by design. No gate or knob excluding the waveform family from the column fold — that is a design decision for espg, not a review fold. The finding's option (1) is what the deployment posture already does for CA (pyramids-off on 0.49, ladder retrofit sweep-only on 0.50), and the phase-3 SERC probe is 4 shards, which fits; option (2) — measuring the column hook at GEDI scale before any build-time pyramid = {} on a full AOI — is the open question, and it stays standing on this thread.

Gates: ruff check src tests clean except the pre-existing N818 in registry.py; ruff format --check clean; pytest tests/test_column.py tests/test_sweep_overview.py tests/test_sweep_stage.py 343 passed.

waveform shard aggregates pooled or single-block-spill only.)

What the words say is ruling 2's honesty property: a waveform record's
samples share one instant, so a single-shot cell's centroids all carry that
Expand Down
154 changes: 154 additions & 0 deletions tests/test_spill_crossblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,160 @@ def _pooled_build(meta, n=8):
return resolve_function(meta["function"])(cell_data["h_ph"], **params)


_WAVEFORM_FUNCTION = "zagg.stats.waveform.build_waveform_digest"


def _waveform_variables(temporal=True, delta=64):
"""A ``rx_flux``-shaped field: the gedi01b template's reducer + channel.

Sources ``h_ph`` and reads the noise-model columns via the params-as-column
path, exactly as the shipped template wires ``rxwaveform``/``noise_mean``/
``noise_stddev`` (issue #508 phase 1 harness).
"""
field = {
"kind": "ragged",
"function": _WAVEFORM_FUNCTION,
"source": "h_ph",
"inner_shape": [2],
"dtype": "float32",
"fill_value": 0,
"params": {
"delta": delta,
"counts": "wf_counts",
"noise_mean": "wf_noise_mean",
"noise_stddev": "wf_noise_stddev",
"gain": 1.0,
"false_positive_rate": 1.0e-3,
"samples_per_record": 60,
},
}
if temporal:
field["temporal"] = "per-centroid"
return {
"count": {"function": "len", "source": "h_ph", "dtype": "int32", "fill_value": 0},
"rx_flux": field,
}


def _with_waveform_columns(dfs, seed=0):
"""Add the noise-model columns ``_waveform_variables`` declares.

Counts sit well above the zero-mean noise floor, so every row survives the
clip and ``sum(weights)`` over a cell is the sum of its rows' counts.
"""
rng = np.random.default_rng(seed)
for df in dfs:
n = len(df)
df["wf_counts"] = rng.uniform(5.0, 50.0, n)
df["wf_noise_mean"] = np.zeros(n, dtype=np.float64)
df["wf_noise_stddev"] = np.full(n, 0.1, dtype=np.float64)
return dfs


class TestWaveformSpillBaseline:
"""How the spill path treats ``build_waveform_digest`` TODAY (issue #508).

Phase 1 characterization, recorded before any registry changes: the SERC
GEDI fleet runs (0.47–0.49) completed under ``{mode: spill}``, and the
mechanism that carried them is the NON-mergeable single-block regime.
``validate_spill_fold`` refuses the builder (it is outside

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 from Claude (review)

The recorded spill baseline is a 2-field synthetic, and the docstring's causal claim does not hold for the config the SERC runs actually used.

The docstring attributes the non-mergeable verdict to the builder alone:

the mechanism that carried them is the NON-mergeable single-block regime — validate_spill_fold refuses the builder (it is outside _TDIGEST_SPILL_FUNCTIONS)

For the shipped gedi01b_waveform_healpix_hive the refusal is over-determined — the builder is one of eight problems, and the other seven are the per-shot companions:

$ python -c "validate_spill_fold(default_config('gedi01b_waveform_healpix_hive'))"
spill blocks cannot cross-block fold this config:
  field 'rx_flux': ragged function 'zagg.stats.waveform.build_waveform_digest' has no fold law ...
  field 'shot_count': scalar function 'zagg.stats.waveform.shot_count' has no cross-block fold ...
  field 'shot_number': scalar function 'zagg.stats.waveform.shot_number' ...
  field 'noise_mean' / 'noise_stddev' / 'rx_energy' / 'elevation_bin0' / 'elevation_lastbin':
      scalar function 'zagg.stats.waveform.single_shot_value' ...

The template's own comment says the same thing (worker.extra_disk): "validate_spill_fold rejects it (build_waveform_digest and the single_shot_value companions have no cross-block fold law)".

Why it matters for a characterization phase: _waveform_variables() declares only count + rx_flux, so the pinned baseline is a config where the builder is the sole cause. A later reader — or phase 2 — could conclude from this class that admitting build_waveform_digest to a shared digest-family registry would change the GEDI store's spill posture. It would not: the seven scalar companions keep the shipped config non-mergeable regardless, so spill_blocks_closed: 0 stays a hard precondition of the deployed template. That is the load-bearing baseline fact and it is currently unrecorded.

Suggested fix — one extra pin on the real config, plus a docstring correction:

def test_the_shipped_template_is_refused_by_more_than_the_builder(self):
    # The deployed config's refusal is OVER-DETERMINED: even if the builder
    # were admitted, the seven per-shot companions keep it non-mergeable, so
    # issue #508 cannot move the GEDI store off single-block spill.
    from zagg.config import default_config

    with pytest.raises(ValueError) as exc:
        validate_spill_fold(default_config("gedi01b_waveform_healpix_hive"))
    assert "'rx_flux'" in str(exc.value)
    assert "'shot_count'" in str(exc.value) and "single_shot_value" in str(exc.value)

and reword the docstring's second sentence to "validate_spill_fold refuses the builder and the per-shot single_shot_value/shot_* companions".

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 0de5dbf.

New pintest_the_shipped_template_is_refused_by_more_than_the_builder runs validate_spill_fold(default_config("gedi01b_waveform_healpix_hive")) and asserts the refusal names rx_flux/build_waveform_digest and all seven per-shot companions (shot_count, shot_number, noise_mean, noise_stddev, rx_energy, elevation_bin0, elevation_lastbin) plus zagg.stats.waveform.single_shot_value. So the over-determination is recorded field-by-field: even if issue #508 admitted the builder, the deployed store stays non-mergeable and spill_blocks_closed: 0 remains a hard precondition.

Docstring corrected — it no longer attributes the shipped templates verdict to the builder alone; it now reads that validate_spill_fold refuses the builder "and on the SHIPPED template the refusal is OVER-determined, naming the per-shot single_shot_value/shot_* scalar companions alongside it", and says explicitly that the 2-field synthetic isolates the builder while the last test pins the deployed config where it is not the only cause. The new tests comment quotes the templates own worker.extra_disk line for provenance.

tests/test_spill_crossblock.py::TestWaveformSpillBaseline green (5 passed; 29 across both targeted classes, 302 across the three touched suites).

``_TDIGEST_SPILL_FUNCTIONS``) — and on the SHIPPED template the refusal is
OVER-determined, naming the per-shot ``single_shot_value``/``shot_*``
scalar companions alongside it — so ``SpillAggregator`` records the
verdict, replays the pooled machinery byte-identically while the shard
fits in one block, and raises ``SpillOverflowError`` naming the field on
the first block close. The synthetic below declares only ``count`` +
``rx_flux`` so the builder is isolated as the cause; the last test pins
the deployed config, where it is not the only one. Issue #508 changes
NONE of this: the shared digest-family registry feeds the D24 pyramid
classification (stored-payload folds), not the build-time spill fold,
which would need the noise-model columns re-threaded per block.
"""

def test_probe_refuses_the_waveform_builder(self):
# The per-centroid channel passes the temporal arm (issue #477); the
# refusal is the ragged function arm — no cross-block fold law.
cfg = _config(_waveform_variables(), streaming=_SPILL)
with pytest.raises(ValueError, match="'rx_flux'.*build_waveform_digest.*fold law"):
validate_spill_fold(cfg)

def test_spill_accepts_the_config_as_non_mergeable(self):
# Accepted — single-block exact — with the probe's verdict recorded so
# a block close can name the field (issue #474 message discipline).
cfg = _config(_waveform_variables(), streaming=_SPILL)
agg = SpillAggregator(cfg, _grid(cfg), "pandas", 1)
assert not agg._mergeable
assert agg._digest_fields == {}
assert "rx_flux" in agg._fold_problems and "fold law" in agg._fold_problems
agg.close()

def test_single_block_regime_is_byte_identical_to_pooled(self, monkeypatch):
# The SERC mechanism: one block -> the pooled replay, payload AND the
# per-centroid temporal channel byte-identical to the pooled path.
key = _shard_key()
pooled_cfg = _config(_waveform_variables())
spill_cfg = _config(_waveform_variables(), streaming=_SPILL)
grid = _grid(pooled_cfg)
dfs = _with_waveform_columns(
_granule_dfs(grid, key, _CELL_LISTS[:3], obs_per_cell=40, seed=6, times=True)
)
df_p, ragged_p, _ = _run(monkeypatch, pooled_cfg, grid, key, list(dfs))
df_s, ragged_s, _ = _run(monkeypatch, spill_cfg, _grid(spill_cfg), key, list(dfs))
pd.testing.assert_series_equal(df_p["count"], df_s["count"])
assert set(ragged_p) == set(ragged_s) == {"rx_flux"}
pay_p, idx_p, locs_p, times_p = _channels_of(ragged_p["rx_flux"])
pay_s, idx_s, locs_s, times_s = _channels_of(ragged_s["rx_flux"])
assert idx_p == idx_s and len(pay_p) > 0
assert locs_p is None and locs_s is None
for a, b in zip(pay_p, pay_s, strict=True):
np.testing.assert_array_equal(a, b)
for a, b in zip(times_p, times_s, strict=True):
np.testing.assert_array_equal(a, b)

def test_block_close_raises_overflow_naming_the_field(self, monkeypatch):
# The recorded boundary of the mechanism above: past one block there is
# no fold law, and the overflow splices the probe's verdict.
from zagg.processing.spill import SpillOverflowError

_force_tiny_blocks(monkeypatch)
key = _shard_key()
cfg = _config(_waveform_variables(), streaming=_SPILL)
grid = _grid(cfg)
dfs = _with_waveform_columns(
_granule_dfs(grid, key, _CELL_LISTS[:2], obs_per_cell=10, seed=1, times=True)
)
with pytest.raises(SpillOverflowError, match="'rx_flux'.*build_waveform_digest.*fold law"):
_run(monkeypatch, cfg, grid, key, dfs)

def test_the_shipped_template_is_refused_by_more_than_the_builder(self):
# The synthetic above isolates the builder; the DEPLOYED config is
# over-determined. Seven of its eight fields are per-shot scalars with
# no cross-block fold either, so even if issue #508 admitted
# ``build_waveform_digest`` to a shared digest-family registry the GEDI
# store would stay non-mergeable -- ``spill_blocks_closed: 0`` remains a
# hard precondition of the shipped template. Its own
# ``worker.extra_disk`` comment says the same: "validate_spill_fold
# rejects it (build_waveform_digest and the single_shot_value
# companions have no cross-block fold law)".
from zagg.config import default_config

with pytest.raises(ValueError) as exc:
validate_spill_fold(default_config("gedi01b_waveform_healpix_hive"))
message = str(exc.value)
assert "'rx_flux'" in message and "build_waveform_digest" in message
for name in (
"shot_count",
"shot_number",
"noise_mean",
"noise_stddev",
"rx_energy",
"elevation_bin0",
"elevation_lastbin",
):
assert f"'{name}'" in message
assert "zagg.stats.waveform.single_shot_value" in message


class TestSpillFoldProbe:
"""The spill mergeability probe (validate_spill_fold) vs merge mode."""

Expand Down
23 changes: 23 additions & 0 deletions tests/test_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,29 @@ def test_non_tdigest_ragged_rejected(self):
with pytest.raises(ValueError, match="merge law"):
validate_streaming(cfg)

def test_waveform_digest_ragged_rejected(self):
# Issue #508 phase 1 baseline: build_waveform_digest is outside
# _TDIGEST_FUNCTIONS, so merge mode refuses it through the same
# no-merge-law arm as any other non-tdigest ragged reducer.
cfg = _config()
cfg.aggregation["variables"]["h_tdigest"]["function"] = (
"zagg.stats.waveform.build_waveform_digest"
)
with pytest.raises(ValueError, match="h_tdigest.*build_waveform_digest.*merge law"):
validate_streaming(cfg)

def test_waveform_temporal_field_refused_via_the_temporal_arm(self):
# The shipped template shape (rx_flux carries temporal: per-centroid):
# the companion refusal fires before the function arm ever sees the
# builder, and routes to mode: spill (issue #508 phase 1 baseline).
cfg = _config()
cfg.aggregation["variables"]["h_tdigest"]["function"] = (
"zagg.stats.waveform.build_waveform_digest"
)
cfg.aggregation["variables"]["h_tdigest"]["temporal"] = "per-centroid"
with pytest.raises(ValueError, match="temporal companions.*cannot stream.*mode: spill"):
validate_streaming(cfg)

def test_pairwise_tdigest_reducer_is_streamable(self):
# build_tdigest_pairwise carries the pairwise merge law (issue #279),
# so it must validate as a mergeable ragged reducer just like the
Expand Down
Loading
Loading