Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
122 changes: 122 additions & 0 deletions tests/test_spill_crossblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,128 @@ 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``), 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. 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)


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
12 changes: 12 additions & 0 deletions tests/test_sweep_overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,18 @@ def test_a_temporal_waveform_field_is_still_none(self):
== "approximate"
)

def test_gedi_waveform_template_classifies_none_today(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)

Phase 1 missed the one gate pair that is already inconsistent — build_tdigest_where — and phase 2's shared registry will silently flip a second shipped template.

The plan comment sets the exit condition for this phase: "The existing tuples keep their exact current members and semantics unless phase 1 proves a gate is already inconsistent, in which case the finding is raised on the issue rather than silently unified." That case exists today and nothing in this commit pins it.

validate_spill_fold already declares the where-stratum reducer a member of the k-way t-digest family (src/zagg/processing/streaming.py):

#: Ragged reducers the SPILL fold additionally accepts (issue #370): ...
#: It folds k-way, like the ``build_tdigest`` it delegates to.
_TDIGEST_WHERE_FUNCTION = "zagg.stats.tdigest.build_tdigest_where"
_TDIGEST_SPILL_FUNCTIONS = (*_TDIGEST_FUNCTIONS, _TDIGEST_WHERE_FUNCTION)

D24 disagrees — it tests _TDIGEST_FUNCTIONS, so a strata field is none even though its stored payload is an ordinary (k, 2) centroid array indistinguishable from build_tdigest's, and the pyramid fold (TDIGEST_LAW) is the same k-way merge. Verified on the shipped strata template:

$ python -c "... composability_classes(default_config('atl03_tdigest_strata_healpix'))"
{'count': 'exact', 'h_tdigest_signal': 'none', 'h_tdigest_noise': 'none', 'composition': 'none'}

That is the same latent bug shape as #508, in a second template, and phase 2 will walk into it: a registry named "folds by the k-way t-digest law" built from the spill-side membership flips both strata fields without a decision being recorded. Simulated the phase-2 change to confirm it is not hypothetical:

# _TDIGEST_FUNCTIONS += (build_waveform_digest, build_tdigest_where)
gedi01b rx_flux            -> approximate        # the intended #508 fix
atl03 strata h_tdigest_*   -> approximate        # collateral, unrecorded

Concretely:

  1. add a phase-1 pin beside test_a_temporal_waveform_field_is_still_none recording today's field_composability({... "function": "zagg.stats.tdigest.build_tdigest_where", "inner_shape": [2], "params": {"where": "h_ph > 0"}}) == "none" (and/or composability_classes(default_config("atl03_tdigest_strata_healpix"))), so phase 2 cannot flip it invisibly; and
  2. raise the D24-vs-validate_spill_fold divergence on issue D24 composability: build_waveform_digest not admitted as approximate — GEDI pyramid declares class none with pyramid on #508 per the plan, so the phase-2 registry's membership for build_tdigest_where is a recorded decision rather than a side effect of the waveform admission.

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, both halves.

(a) Pinned76468a0 adds test_where_strata_template_classifies_none_today to TestComposabilityClasses, recording composability_classes(default_config("atl03_tdigest_strata_healpix")) with h_tdigest_signal and h_tdigest_noise == "none" today, plus the meta-level pin on a build_tdigest_where declaration (params: {where: ...}, matching the templates shape). Its comment states the gate drift is raised on issue #508 and that the phase-2 registry deliberately does NOT admit build_tdigest_where — so the collateral flip you simulated cannot happen silently.

(b) Raised#508 (comment) states the _TDIGEST_SPILL_FUNCTIONS-vs-D24 divergence, cites the docstring that calls it a k-way member, shows the strata classes, and offers the two options: (1) keep strata none (recommended, this PRs posture) or (2) admit build_tdigest_where to the shared registry in a follow-up.

Phase 2 stays scoped to (*_TDIGEST_FUNCTIONS, "zagg.stats.waveform.build_waveform_digest"), with _TDIGEST_FUNCTIONS/_TDIGEST_SPILL_FUNCTIONS membership untouched. tests/test_sweep_overview.py::TestComposabilityClasses green (25 passed).

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 the count in my previous reply: tests/test_sweep_overview.py::TestComposabilityClasses is 24 passed, not 25 (the class had 23 tests before 76468a0). Green either way; the miscount was mine, the fold is unchanged.

# Issue #508 phase 1 baseline: the SHIPPED template's rx_flux —
# build_waveform_digest with a per-centroid clock — is D24 class
# ``none`` today, which is exactly what the SERC probe observed
# (manifest ``{"class": "none"}``, no ladder, even with pyramid on).
# The meta-level pin above records the mechanism; this one records
# that the template hits it.
from zagg.config import default_config

classes = composability_classes(default_config("gedi01b_waveform_healpix_hive"))
assert classes["rx_flux"] == "none"

def test_located_declaration_rides_the_manifest_entry(self):
# The manifest is the only description the overview WRITER has of a
# field (``_overview_config``), so the channel has to be recorded there
Expand Down
Loading