Summary
Tracking the two open decisions surfaced during PR #47's review folds. Both are non-blockers for the merge of #47 — the PR ships sane defaults — but each warrants a deliberate design call before going further. To be addressed in a follow-up PR once decisions land.
1. column: 0 vs .any(axis=1) semantic divergence (signal_conf_ph TEP filter)
Where it stands. Both shipped ATL03 templates (atl03.yaml, atl03_waveform_counts.yaml) carry {column: 0, op: ne, value: -2} against signal_conf_ph to drop TEP photons. The pre-merge _quality_mask did .any(axis=1) (drop iff -2 across every surface-type column); the post-merge _predicate_mask from #43 requires a column for N-D flags and so cannot express the old reduce-across-all behaviour. The two filters agree exactly when TEP is uniform across columns (which the ATL03 v3 data dictionary asserts), but diverge silently if any granule has signal_conf_ph[i, 0] == -2 with non--2 values in other columns.
Background: original analysis at #47 (comment).
Three options:
- (A) Keep
column: 0 as shipped. Accept the divergence on the v3-dictionary guarantee; tighten the YAML comment to make the choice explicit. Zero code change.
- (B) Extend
_predicate_mask to allow column: null on an N-D flag with a documented .any() reduction semantic. Recovers the prior behaviour exactly; ~5 LOC in src/zagg/processing.py, plus a TestPredicateMask case + a mention in the structured-filter docs. The ATL03 templates would then drop the column: 0 key.
- (C) Add an explicit
reduce: any|all knob to the structured filter that, when set with column: null, applies a configurable reduction across the trailing axis. More general than (B), bigger surface, more docs.
Default position: (A) for now — matches the v3 dictionary and is what's shipped. (B) is the safest if TEP is ever partial-column on a future granule format. (C) is overkill for one use case.
2. Refactor: extract a shared post-read helper from _planned_read_group + _read_group_full
Where it stands. Phase 5's self-review (#47 (review)) flagged that src/zagg/processing.py is now ~1448 lines and _planned_read_group is largely a structural twin of _read_group_full — both follow the same template:
read coords -> spatial mask -> variables / filter datasets -> structured filters
-> cross-level mask -> expression filters -> pandas / arrow output
The duplication landed because phase 5 was scoped to "wire the planning seam into the read path" without touching the existing flow. CLAUDE.md §4 targets ~1000 lines per module; we're 45 % over.
Proposed shape. A _apply_filters_and_build_output(...) helper takes:
lats, lons, arrays_by_path (the just-read data),
- the data_source config (for
coordinates, variables, filters),
- shard / grid context,
- optional
global_idx + full-length cross_mask for the planned-path case,
arrow flag.
Returns the same pandas.DataFrame | pyarrow.Table | None. The planned and full paths shrink to "do my IO + hand off."
Open questions:
- Where does the cross-level filter logic live — in the helper, or as a pre-computed mask passed in? (Phase 5 puts it on each path; a shared helper would naturally take the precomputed full-length
cross_mask + global_idx.)
- Does this also subsume
_read_group_full's [min_idx, max_idx] hyperslice optimization, or stay separate? They're structurally different (single contiguous slice vs. multi-slice list).
- Worth adding a
_read_group_kernel benchmark to confirm the shared helper doesn't regress the full-read path.
Lower risk if landed as its own PR with a parity test against both the planned and full paths' outputs.
Phasing / dependencies
Independent. Either can be addressed first; (1) is smaller and might land first if @espg picks (B) or (C). (2) is the bigger cleanup and benefits from being its own self-contained PR.
Refs
Summary
Tracking the two open decisions surfaced during PR #47's review folds. Both are non-blockers for the merge of #47 — the PR ships sane defaults — but each warrants a deliberate design call before going further. To be addressed in a follow-up PR once decisions land.
1.
column: 0vs.any(axis=1)semantic divergence (signal_conf_phTEP filter)Where it stands. Both shipped ATL03 templates (
atl03.yaml,atl03_waveform_counts.yaml) carry{column: 0, op: ne, value: -2}againstsignal_conf_phto drop TEP photons. The pre-merge_quality_maskdid.any(axis=1)(drop iff-2across every surface-type column); the post-merge_predicate_maskfrom #43 requires acolumnfor N-D flags and so cannot express the old reduce-across-all behaviour. The two filters agree exactly when TEP is uniform across columns (which the ATL03 v3 data dictionary asserts), but diverge silently if any granule hassignal_conf_ph[i, 0] == -2with non--2values in other columns.Background: original analysis at #47 (comment).
Three options:
column: 0as shipped. Accept the divergence on the v3-dictionary guarantee; tighten the YAML comment to make the choice explicit. Zero code change._predicate_maskto allowcolumn: nullon an N-D flag with a documented.any()reduction semantic. Recovers the prior behaviour exactly; ~5 LOC insrc/zagg/processing.py, plus aTestPredicateMaskcase + a mention in the structured-filter docs. The ATL03 templates would then drop thecolumn: 0key.reduce: any|allknob to the structured filter that, when set withcolumn: null, applies a configurable reduction across the trailing axis. More general than (B), bigger surface, more docs.Default position: (A) for now — matches the v3 dictionary and is what's shipped. (B) is the safest if TEP is ever partial-column on a future granule format. (C) is overkill for one use case.
2. Refactor: extract a shared post-read helper from
_planned_read_group+_read_group_fullWhere it stands. Phase 5's self-review (#47 (review)) flagged that
src/zagg/processing.pyis now ~1448 lines and_planned_read_groupis largely a structural twin of_read_group_full— both follow the same template:The duplication landed because phase 5 was scoped to "wire the planning seam into the read path" without touching the existing flow. CLAUDE.md §4 targets ~1000 lines per module; we're 45 % over.
Proposed shape. A
_apply_filters_and_build_output(...)helper takes:lats,lons,arrays_by_path(the just-read data),coordinates,variables,filters),global_idx+ full-lengthcross_maskfor the planned-path case,arrowflag.Returns the same
pandas.DataFrame | pyarrow.Table | None. The planned and full paths shrink to "do my IO + hand off."Open questions:
cross_mask+global_idx.)_read_group_full's[min_idx, max_idx]hyperslice optimization, or stay separate? They're structurally different (single contiguous slice vs. multi-slice list)._read_group_kernelbenchmark to confirm the shared helper doesn't regress the full-read path.Lower risk if landed as its own PR with a parity test against both the planned and full paths' outputs.
Phasing / dependencies
Independent. Either can be addressed first; (1) is smaller and might land first if @espg picks (B) or (C). (2) is the bigger cleanup and benefits from being its own self-contained PR.
Refs