Skip to content

feat(v4): item 8a of the 4.0 cut - retire the event-study reference-row sentinel (M-093, first half) - #882

Merged
igerber merged 2 commits into
mainfrom
feat/v4-pr8-m093-sentinel-retirement
Oct 31, 2026
Merged

igerber merged 2 commits into
mainfrom
feat/v4-pr8-m093-sentinel-retirement

Conversation

@igerber

@igerber igerber commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • Item 8a of the 4.0 cut - the first half of [M-093]: the n_groups == 0 / n_obs == 0 reference-row SENTINEL retires from every native event-study dict. Every producer that materializes its normalization row (StackedDiD, ImputationDiD, TwoStageDiD, SpilloverDiD, the CallawaySantAnna/DMLDiD universal-base aggregation carrier) marks it explicitly - "is_reference": True and NO count key - and every remaining zero count is a genuine count (a non-estimable horizon). SpilloverDiD's event-study att_dynamic / spillover_effects frames gain a boolean is_reference column with n_obs = NaN on the reference rows; aggregate-mode frames are unchanged. SunAbraham (omitted row via reference_period) and EfficientDiD (PT-Post anchor, an estimated cell named by reference_period) are untouched.
  • The unified builder results_base._from_relative_dict reads only the is_reference key unioned with the named reference_period: the zero-count heuristic is gone, and a keyed row with a finite nonzero effect raises instead of being silently normalized to 0. A reference-only TwoStageDiD surface carries n_kind=None.
  • 3.x pickles migrate on load through _deprecation.migrate_event_study_reference_sentinel (the single place the retired sentinel is still recognized), run by the __setstate__ of the CS / Stacked / Imputation / TwoStage carriers and a new SpilloverDiDResults.__setstate__ (frame column included). Fresh pickles round-trip as a no-op.
  • Consumers take the minimal swap: plot_event_study (attribute, else key, else the -1 fallback), plot_honest_event_study (signature + verified anchor), HonestDiD, PreTrendsPower and DiagnosticReport replace the count conjunct with the key; dispatch, admission, rendering and exception types are unchanged. The Stacked / Imputation / TwoStage summary() renderers key [ref: h] on the marker (TwoStageDiD's genuine all-filtered horizons now print as NaN rows); the bootstrap loops skip the marked row; the native to_dataframe(level="event_study") frames render the reference row's n_obs as NaN until the 8b schema rewire.
  • Ledger: M-093 gains test_ref and a ninth amendment note and stays planned until 8b (the per-class to_dataframe(level="event_study") schema enforcement). Spec item 8, REGISTRY (SpilloverDiD shape, Stacked/Imputation/TwoStage reference-row Notes, plotting and relay Notes, CS post-fit Note), migration guide, llms-full.txt and docs/api/spillover.rst updated; notebook 13 re-validated.

Methodology references (required if estimator / math changes)

  • Method name(s): N/A - no estimator math changes. Results-contract change only (reference-row marking in the native event-study surfaces; spec section 5, ledger row M-093).
  • Paper / source link(s): docs/v4-design.md section 5 ("explicit reference-period marking via an is_reference column - no sentinel-value conventions"); REGISTRY SpilloverDiD / StackedDiD / ImputationDiD / TwoStageDiD reference-row Notes and the Event Study Plotting Notes.
  • Any intentional deviations from the source (and why): None. Point estimates, SEs and inference never move; only the marker of the normalization row changes.

Validation

  • Tests added/updated: new tests/test_v4_sentinel_retirement.py (M-093 test_ref: native dict and frame shape on every materializing producer, the CS carrier dict captured at the builder boundary, the count-agnostic builder incl. the keyed-nonzero guard and the reference-only n_kind=None case, pickle migration on every carrier plus aggregate-mode Spillover and fresh round trips, the consumers' key reads with raw/container parity for HonestDiD and PreTrendsPower, summary(), bootstrap skip, ledger). tests/helpers/legacy_surface.py emits the keyed shape; count-key pins moved to the key in test_event_study_surface.py, test_aggregate_contract.py, test_stacked_did.py, test_imputation.py, test_two_stage.py, test_spillover.py, test_staggered.py, test_honest_did.py, test_pretrends_event_study.py, test_event_study_consumers.py, test_methodology_callaway.py, test_methodology_stacked_did.py, test_diagnostic_report.py, test_visualization_new.py, test_visualization_plotly.py; tests/_capture_v4_aggregate_oracles.py reads the key (the committed oracle is unchanged).
  • Backtest / simulation / notebook evidence (if applicable): docs/tutorials/13_stacked_did.ipynb re-validated with nbmake under the Rust backend (stores no outputs). Full default suite green; mypy clean; py3.9 compile clean.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

Changelog

  • changelog.d/ fragment added (or N/A - no user-visible change): Yes (changelog.d/20261030-v4-pr8a-m093-sentinel-retirement.md)

…ow sentinel (M-093, first half)

Through 3.x every native event-study dict marked its normalization
(reference) row with a zero-count sentinel: n_groups == 0 on the
CallawaySantAnna aggregation, n_obs == 0 on StackedDiD, ImputationDiD,
TwoStageDiD and SpilloverDiD. Every producer that materializes that row
now marks it explicitly - "is_reference": True and NO count key - and
every remaining zero count is a genuine count (a non-estimable horizon:
TwoStageDiD's all-filtered horizons, SpilloverDiD's rectangular cells).
SpilloverDiD's event-study att_dynamic / spillover_effects frames gain a
boolean is_reference column with n_obs = NaN on the reference rows
(aggregate-mode frames, which have no reference row, are unchanged).
SunAbraham (omitted row, reference_period) and EfficientDiD (PT-Post
anchor, an estimated cell named by reference_period) are untouched.

The unified builder results_base._from_relative_dict reads only the
is_reference key unioned with the named reference_period: the zero-count
heuristic is gone, and a keyed row with a finite nonzero effect raises
instead of being silently normalized to 0 by __post_init__. A
reference-only TwoStageDiD surface now carries n_kind=None (no row
records a count).

3.x pickles migrate on load: _deprecation.migrate_event_study_reference_sentinel
rewrites a zero-count row whose effect is exactly 0.0 into the keyed
shape (NaN-effect zero-count rows stay genuine) and is the single place
the retired sentinel is still recognized - run by the __setstate__ of
CallawaySantAnnaResults (DMLDiD inherits), StackedDiDResults,
ImputationDiDResults, TwoStageDiDResults and a new
SpilloverDiDResults.__setstate__ (which also adds the frame column for
event-study-mode fits). A fresh pickle round-trips as a no-op.

Consumers take the minimal swap: plot_event_study's raw-dict route reads
the reference_period attribute, else the is_reference key, else today's
-1 fallback; plot_honest_event_study's constraint-row signature and
verified-anchor check, HonestDiD's native CS filter / reference
detection / _estimate_max_pre_violation, PreTrendsPower's CS and
SunAbraham filters and DiagnosticReport's pre-period / post-effect
collectors replace the count conjunct with the key (genuine zero counts
stay excluded from n_dropped_undefined). Dispatch, admission, rendering
and exception types are unchanged. The Stacked / Imputation / TwoStage
summary() renderers key their [ref: h] line on the marker - TwoStageDiD's
genuine all-filtered horizons therefore print as NaN rows instead of a
mislabeled reference line; the bootstrap loops skip the marked row. The
native to_dataframe(level='event_study') frames render the reference
row's n_obs as NaN (float column) until the 8b schema rewire.

Tests: new tests/test_v4_sentinel_retirement.py (M-093 test_ref) pins
the native dict and frame shape on every materializing producer, the CS
carrier dict (captured at the builder boundary, not reconstructed), the
count-agnostic builder (unkeyed zero count marks nothing; keyed row with
NaN count; keyed nonzero effect raises; reference-only dict -> n_kind
None), the pickle migration on every carrier plus the aggregate-mode
Spillover no-op and fresh round trips, the consumers' key reads (plot
extractor, honest plot anchor + all-undefined guard, raw/container
parity for HonestDiD and PreTrendsPower, DiagnosticReport collector,
summary(), bootstrap skip) and the ledger row. tests/helpers/legacy_surface
emits the keyed shape; the enumerated count-key pins across the stacked,
imputation, two-stage, spillover, staggered, honest, pretrends, consumer,
methodology and diagnostic-report suites move to the key; the
visualization / DR hand-built fixtures mark the reference by key (one DR
fixture keeps the genuine zero-count shape).

Ledger: M-093 gains test_ref and a ninth amendment note; it stays planned
until 8b (the to_dataframe(level='event_study') schema rewire on every
results class, incl. the dCDH native frame's estimand 'ref' row). Spec
item 8 records 8a shipped; REGISTRY (SpilloverDiD reference-row shape,
StackedDiD / ImputationDiD / TwoStageDiD reference-row Notes, the
plotting and relay Notes, the CS post-fit Note), the migration guide,
llms-full.txt and docs/api/spillover.rst describe the marking; notebook
13 re-validated with nbmake.
@github-actions

Copy link
Copy Markdown

Overall assessment

✅ Looks good — no unmitigated P0 or P1 findings identified in the reviewed diff.

Executive summary

  • Reference marking is consistently propagated through producers, consumers, bootstrap paths, and pickle migration.
  • No changes to estimated effects, weighting, variance formulas, or identification assumptions were found.
  • Genuine empty horizons remain distinct from normalization rows.
  • Migration smoke checks and Python 3.9 syntax parsing passed; regression tests could not run because pytest and NumPy are unavailable.
  • The required changelog fragment is present.

Methodology

No actionable findings.

Reviewed Callaway–Sant’Anna/DMLDiD, StackedDiD, ImputationDiD, TwoStageDiD, SpilloverDiD, and the affected HonestDiD/PreTrendsPower consumers against the Registry, docstrings, and available repository paper reviews. The changes preserve the documented normalization and inference semantics.

The builder rejects explicitly marked reference rows with finite nonzero effects (diff_diff/results_base.py:L1182-L1197). Genuine zero-count horizons remain unmarked and retain their undefined estimates.

Code Quality

No findings. The changed paths consistently read the explicit marker, and no new inline inference computation or partial NaN guard was introduced.

Performance

No findings. Added bookkeeping is linear in the event-study surface size; estimation routines are unchanged.

Maintainability

No findings. Legacy sentinel recognition is centralized in migrate_event_study_reference_sentinel (diff_diff/_deprecation.py:L229-L278).

Tech Debt

P3 — Informational: unified DataFrame schema remains scheduled for item 8b.

  • Impact: Native event-study frames retain their current schemas during this transition.
  • Concrete fix: No action required for this PR. The remaining work is explicitly documented in docs/v4-design.md:L1242-L1247 and the Registry’s reference-row Notes.

Security

No findings. No added secrets or new external execution paths were identified.

Documentation/Tests

No actionable findings. The migration guide, Registry, API documentation, and changelog describe the behavior change. Added tests cover reference marking, empty horizons, migration, consumer parity, and bootstrap exclusion.

Validation: Migration smoke checks passed, including input preservation and idempotence. All 20 changed production modules passed Python 3.9 syntax parsing. The pytest suite remains unverified in this environment because pytest and NumPy are unavailable.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Oct 30, 2026
The CI test legs install only the core stack (numpy, pandas, scipy,
pytest), so test_honest_plot_anchor_from_key_and_all_undefined_guard
failed on every leg with the plotter's "matplotlib is required"
ImportError. The test now follows the
test_event_study_consumers.py::TestPlotting convention - importorskip
matplotlib, force the Agg backend, close figures on exit - so it runs
wherever matplotlib is present and skips where it is not. The sentinel
retirement itself is unchanged.
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: a6b5eb2f145214853eb97c593c50ccc3fbf0dcdb


Overall assessment

✅ Looks good — no unmitigated P0 or P1 findings identified. The prior review’s assessment remains supported.

Executive summary

  • Explicit reference marking is propagated through producers, consumers, summaries, bootstrap paths, and pickle migration.
  • Genuine zero-count horizons remain distinguishable from normalization rows.
  • No changes to estimator equations, weighting, variance, or identification assumptions were found.
  • Migration smoke checks, Python 3.9 syntax checks, and changelog validation passed.
  • Regression tests remain unverified here because NumPy, pandas, SciPy, and pytest are unavailable.

Methodology

No actionable findings.

Cross-checked the affected Callaway–Sant’Anna/DMLDiD, StackedDiD, ImputationDiD, TwoStageDiD, and SpilloverDiD paths and HonestDiD/PreTrendsPower consumers against the Registry, docstrings, and available repository source-paper audits. The changes preserve the documented normalization and inference semantics.

The builder rejects explicitly marked reference rows with finite nonzero effects while preserving genuine empty horizons (diff_diff/results_base.py:L1173-L1202). The reference-row contracts are documented in Registry Notes, including docs/methodology/REGISTRY.md:L2339, L2436, and L2589.

Code Quality

No findings. No new inline inference computation or partial NaN guard was identified. Count-key removal is handled by the affected readers.

Performance

No findings. Added marker bookkeeping and migration scans are linear in surface size; numerical estimation routines are unchanged.

Maintainability

No findings. Legacy sentinel conversion is centralized in migrate_event_study_reference_sentinel (diff_diff/_deprecation.py:L228-L278) and invoked by the affected result carriers.

Tech Debt

P3 — Informational: native DataFrame schema enforcement remains scheduled for item 8b.

  • Impact: Native event-study frames retain transitional schemas.
  • Concrete fix: No action required in this PR. The remaining work is explicitly documented in docs/v4-design.md:L1242-L1247 and the Registry’s reference-row Notes.

Security

No findings. No added secrets or new external execution paths were identified.

Documentation/Tests

No actionable findings. The required changelog fragment is present and passes the repository validator. Added tests cover reference marking, genuine empty horizons, migration, consumer parity, and bootstrap exclusion. The plotting test now guards its optional dependency (tests/test_v4_sentinel_retirement.py:L496-L502).

Validation performed: migration smoke checks passed, including input preservation and idempotence; all 20 changed production modules and the new regression module passed Python 3.9 syntax parsing. Numerical regression tests could not run with the dependencies available here.

@igerber
igerber merged commit c32d20b into main Oct 31, 2026
35 checks passed
@igerber
igerber deleted the feat/v4-pr8-m093-sentinel-retirement branch October 31, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant