Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
22 changes: 21 additions & 1 deletion src/zagg/configs/gedi01b_waveform_healpix_hive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@ aggregation:
fill_value: 0
# weights: flux # PR #431 rebase: the section-2.0 weights
# # declaration lands there; enable on rebase.
# # Load-bearing for the pyramid below, not just
# # for 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. Commented
# # out, leaf and overview agree by ABSENCE --
# # which spec section 2.0 makes readers take as
# # `counts`, i.e. integer observation counts,
# # while these weights are (count - noise_mean)
# # * gain floats. Enable this before building a
# # GEDI ladder anyone will analyze; until then
# # every level is mis-declared the same way the
# # leaf already is.
params:
delta: 8192 # ratified cross-sensor floor (issue #422, decision 7)
counts: rxwaveform # params-as-column: per-sample ADC values
Expand Down Expand Up @@ -267,7 +282,12 @@ 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.

# -- but read rx_flux's `weights: flux` note
# first: until PR #431 enables it, every level
# of that ladder declares itself `counts`.

worker:
memory: 4096 # optimized probe (run 5d081b68, 4/4 green):
Expand Down
26 changes: 26 additions & 0 deletions src/zagg/processing/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,32 @@
_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 BOTH
#: stored-payload fold sites it licenses: the sweep's overview fold
#: (:func:`zagg.sweep_overview.fold_digests`) and the worker-side leaf column
#: (:func:`zagg.column.fold_column`, gated by
#: :func:`zagg.column.leaf_column_plan`, which filters the same declaration
#: through :func:`zagg.column.composable_fields`). Membership here is
#: therefore not free at build time — see ``build_waveform_digest``'s
#: docstring for why a GEDI-scale store declares its ladder sweep-only.
#: 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
28 changes: 21 additions & 7 deletions src/zagg/stats/waveform.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,27 @@ 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 waveform
shard aggregates pooled or single-block-spill only), but the build-time
WORK is not: ``leaf_column_plan`` filters the declaration through the same
composable classes, so a pyramid-ON waveform config also folds this column
worker-side (:func:`zagg.column.fold_column` at the tail of
``hive.process_and_write_hive``) — a node-order k-way merge over every
resident digest, whose measured envelope
(:func:`zagg.column.write_leaf_column`'s memory note: ~2.0 GB at ~17.6M
centroids, on top of a loaded 4 GB heap) is NOT validated at GEDI's
21-33M-kept-rows-per-shard scale. So the ruled deployment path for a
GEDI-scale store is pyramids-OFF aggregation, then
:func:`zagg.sweep_overview.declare_pyramid` plus a sweep-only overview
pass (the runbook contingency) — which builds the ladder without ever
putting the column fold on the aggregating worker.

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
43 changes: 43 additions & 0 deletions tests/test_column.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,49 @@ def test_all_none_fields_mean_no_column_at_all(self):
assert leaf_column_plan(cfg, grid) is None


class TestWaveformEntersTheColumn:
"""Issue #508 review pin: the D24 class flip is not confined to the sweep.

``leaf_column_plan`` filters the declaration through the same
``composable_fields``, so an ``approximate`` rx_flux now enters the
WORKER-side fold (:func:`zagg.column.fold_column` at the tail of
``hive.process_and_write_hive``) the moment the shipped GEDI template
declares a pyramid — a consequence recorded here rather than discovered on
a fleet run. The memory envelope of that fold at GEDI scale is unvalidated
(see ``write_leaf_column``'s memory note), which is why the runbook builds
CA pyramids-off and retrofits the ladder sweep-only (``declare_pyramid``).
"""

def _cfg(self):
from zagg.config import default_config

return default_config("gedi01b_waveform_healpix_hive")

def test_shipped_template_declares_no_column(self):
from zagg.column import leaf_column_plan
from zagg.grids import from_config

# ``pyramid: false`` as shipped: no declaration, so no column at all.
cfg = self._cfg()
assert leaf_column_plan(cfg, from_config(cfg)) is None

def test_pyramid_knob_folds_rx_flux_worker_side(self):
from zagg.column import leaf_column_plan
from zagg.grids import from_config

cfg = self._cfg()
cfg.output["pyramid"] = {} # the /2 default flip at chunk_inner 12
plan = leaf_column_plan(cfg, from_config(cfg))
assert plan is not None
resolutions, fields = plan
assert resolutions == [12, 11, 10, 9]
# Pre-#508 this was ``{"count"}`` — the digest-family membership is
# what adds the second entry, and rx_flux is the ragged one.
assert set(fields) == {"count", "rx_flux"}
assert fields["rx_flux"]["class"] == "approximate"
assert fields["rx_flux"]["temporal"] == "per-centroid"


class TestLeafSlabs:
def test_staged_refs_pass_through(self):
slabs = _cell_slabs({0: [1.0, 2.0]})
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
Loading
Loading