Skip to content

feat(v4): results-contract foundation - BaseResults, Diagnostic marker, unified event-study surface (Phase 2a)#710

Merged
igerber merged 3 commits into
mainfrom
feat/v4-phase2a-results-foundation
Jul 20, 2026
Merged

feat(v4): results-contract foundation - BaseResults, Diagnostic marker, unified event-study surface (Phase 2a)#710
igerber merged 3 commits into
mainfrom
feat/v4-phase2a-results-foundation

Conversation

@igerber

@igerber igerber commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 2 PR (a) of the 4.0 API-unification program (docs/v4-design.md §9a). Strictly additive to the estimator surface; the only behavior change is diagnostic input-validation in the report consumers (ledger row M-091). Symbols described as future 4.0 surface intentionally do not exist at HEADtests/test_v4_matrix.py enforces their absence until their phase ships.

  • New leaf module diff_diff/results_base.py (numpy/pandas only, cycle-free):
    • BaseResults — shared estimator-results base; a transitional marker in 3.9 that anchors the 4.0 storage flip and the estimand self-description hook.
    • Diagnostic — marker base on the 17-member diagnostic result roster (Bacon, RDPlot, HonestDiD/Sensitivity, pre-trends/power, placebo, HAD pretests, DiagnosticReport results). Marked results carry no inference row.
    • EventStudyResults — the unified per-event-time representation (columnar, pinned to_dataframe schema, explicit is_reference marking — no count sentinels). build_event_study_surface() adapts all 14 event-study producers; public exposure rides aggregate(type="event_study") in a later Phase 2 PR. Producer-aware reference resolution (explicit reference_period / synthesized anchor / finite-effect zero-count sentinel); multiple and zero reference rows both legal; JSON-safe calendar labels; vcov round-trips.
  • Serialization completeness: seven estimator results classes gain to_dict() (CS, Stacked, Continuous, SunAbraham, Wooldridge, dCDH, Bacon); DiagnosticReportResults gains summary()/to_dict()/to_dataframe() (moved from the builder, which now delegates); PreTrendsPowerCurve gains summary().
  • Consumer propagation (M-091): BusinessReport, practitioner_next_steps, and DiagnosticReport route diagnostics by the marker instead of by result-class name. Passing a marked non-Bacon diagnostic as a primary estimator input now raises TypeError (previously: name-only Bacon special-case in BusinessReport; silent zero-check report in DiagnosticReport). Estimator numerical output is unchanged.
  • Ledger / spec / docs: M-091 → done; M-092 (unified event-study surface, born done) and M-093 (4.0 sentinel retirement, phase 5) added. Matrix row-count floor 75→77; immutable id snapshot extended by (92, 93). Spec §5/§11 citations; doc-deps.yaml entry for the new module; api/results.rst + api/index.rst paired :no-index: autoclass / autosummary; M-090 code_refs += tests/test_docs_ia.py.

Follow-up tracked in TODO.md: persist the full event-study VCV and thread per-producer inference df for StackedDiD/TwoStageDiD — additive estimator-container work, faithful adapter behavior today (those containers retain only marginal SEs).

Methodology references

  • Method name(s): No estimator equations, weighting, variance, standard errors, identification assumptions, or numerical defaults change. The change is a taxonomy/result-contract layer plus a diagnostic-input-validation switch in the report consumers.
  • Paper / source link(s): N/A — no methodology change. Reference/normalization conventions preserved (Sun–Abraham e=-1-anticipation omitted baseline; CS universal-base cohort positional bases; dCDH l1_first_switch).
  • Any intentional deviations from the source (and why): None.

Validation

  • Tests added/updated: tests/test_diagnostic_marker.py, tests/test_event_study_surface.py, tests/test_results_serialization.py, tests/test_v4_matrix.py (row-count + snapshot), plus tests/helpers/results_foundation.py. All fits are small and analytical.
  • Bit-exact quintet parity vs the native representation on non-reference rows across all 14 producers; black/ruff/mypy clean; Sphinx -W clean.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

@github-actions

Copy link
Copy Markdown

Overall assessment

⚠️ Needs changes

Executive summary

  • Estimator equations and native inference calculations are unchanged.
  • P1: Sun–Abraham can receive a fabricated reference row on gapped event-time grids.
  • Reference-row NaN normalization is otherwise consistent with the registry.
  • Missing VCV/df propagation is properly tracked in TODO.md.
  • The public container may mutate caller-owned arrays.
  • Test execution was unavailable because pytest/NumPy are not installed.

Methodology

  • P1 — False Sun–Abraham reference on gapped grids
    Location: diff_diff/results_base.py:L562-L589
    Impact: in_support only checks whether the reference lies between the minimum and maximum effect keys. For native keys {-2, 0}, it fabricates e=-1 even though that period was never observed. Excluding an absent dummy in the native regression does not create an identifying anchor, so downstream consumers receive a misleading zero-effect reference row.
    Concrete fix: Persist actual reference-period support from the Sun–Abraham fit and synthesize the row only when e=-1-anticipation was genuinely observed. Add a gapped-grid regression test with keys {-2, 0} asserting no reference row.

  • P3 — Tracked VCV/df omissions
    Location: TODO.md:L26
    Impact: StackedDiD/TwoStageDiD VCVs and several producer-specific inference degrees of freedom are not retained. This is explicitly tracked and does not alter current stored inference.
    Concrete fix: No approval action required; implement the recorded follow-up.

Code Quality

  • P2 — Constructor mutates caller-owned arrays
    Location: diff_diff/results_base.py:L242-L248, diff_diff/results_base.py:L301-L312
    Impact: np.asarray() may retain the caller’s buffers, after which reference normalization overwrites them. Read-only inputs also fail unexpectedly.
    Concrete fix: Copy all normalized arrays with np.array(..., copy=True) and test both input preservation and read-only arrays.

Performance

No findings.

Maintainability

No findings.

Tech Debt

The material VCV/df limitation is appropriately tracked in TODO.md; no untracked blocking debt found.

Security

No findings or exposed secrets identified.

Documentation/Tests

  • P3 — Producer count is stale
    Location: docs/v4-design.md:L373-L375
    Impact: Documentation says there are five event-study estimators while this PR adapts fourteen.
    Concrete fix: Update the count to fourteen.

The new suite covers all listed producers and several reference edge cases, but misses the in-range gapped Sun–Abraham case above.

Path to Approval

  1. Make Sun–Abraham reference synthesis depend on actual observed support, and add the {-2, 0} gapped-grid regression test.

igerber added a commit that referenced this pull request Jul 19, 2026
… safety

Address CI review on #710:

- P1 (SunAbraham false reference on gapped grids): the min<=e<=max range
  guard could not distinguish the omitted-but-observed baseline from a
  genuine gap (native keys {-2, 0} with -1 never observed would fabricate an
  anchor). SunAbrahamResults now persists reference_period + reference_observed
  (computed at fit as `-1-anticipation in all_rel_times`), and the adapter
  synthesizes the anchor ONLY when it was genuinely observed. This unifies
  reference handling: a named reference is MARKED if present (SpilloverDiD's
  materialized zero row) or SYNTHESIZED if omitted-but-observed (SunAbraham);
  the separate _from_sun_abraham path and the range heuristic are removed.
- P2 (caller-array mutation): EventStudyResults.__post_init__ normalizes
  reference rows in place, so it now copies inputs (np.array, not np.asarray)
  - caller-owned arrays are never overwritten and read-only inputs are
  accepted.
- P3 (producer count): spec section 5 said "today there are five"; corrected
  to "fourteen event-study producers use four incompatible native shapes".

Tests: gapped-grid {-2,0} observed/unobserved cases, caller-array
non-mutation, read-only-input acceptance.
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 578e958451c48b7ccc94b4f5e2d3c2d17022524e


Overall assessment

Looks good

Executive summary

  • The prior P1 is resolved: Sun–Abraham synthesizes a reference row only when the anchor was observed.
  • Caller-owned arrays are now copied before normalization.
  • No estimator equations, weighting, variance, or identification behavior changed.
  • VCV/df omissions remain explicitly tracked and are non-blocking.

Methodology

  • Resolved prior P1 — Sun–Abraham gapped reference: observed support is recorded during fitting and gates synthesis in the adapter (diff_diff/sun_abraham.py:L860-L885, diff_diff/results_base.py:L567-L632). This matches the registry’s e=-1-anticipation convention (docs/methodology/REGISTRY.md:L1406-L1429).
  • P3 — Tracked VCV/df omissions
    Impact: Some producers cannot yet expose full VCV or inference df.
    Concrete fix: No approval action required; follow the tracked item in TODO.md:L26.

Code Quality

  • Resolved prior P2: arrays are copied before in-place reference normalization (diff_diff/results_base.py:L242-L251).
  • No new findings.

Performance

No findings.

Maintainability

No findings.

Tech Debt

The VCV/df follow-up is properly tracked in TODO.md; no untracked blocking debt found.

Security

No findings or exposed secrets identified.

Documentation/Tests

  • Resolved prior P3: documentation now correctly identifies fourteen event-study producers (docs/v4-design.md:L373-L384).
  • Gapped Sun–Abraham, caller-array ownership, read-only arrays, empty results, and NaN reference behavior have regression coverage.
  • Full tests could not run because NumPy is unavailable in the review environment; focused files passed syntax parsing.

@igerber igerber added ready-for-ci Triggers CI test workflows and removed ready-for-ci Triggers CI test workflows labels Jul 19, 2026
…r, unified event-study surface (Phase 2a)

Phase 2 PR (a) of the 4.0 API-unification program (docs/v4-design.md sec 9a).
Strictly additive to the estimator surface; the only behavior change is
diagnostic input-validation in the report consumers (ledger row M-091).
Symbols described as future 4.0 surface intentionally do not exist at HEAD;
tests/test_v4_matrix.py enforces their absence until their phase ships.

New leaf module diff_diff/results_base.py (numpy/pandas only, cycle-free):
- BaseResults: shared estimator-results base, a transitional marker in 3.9
  that anchors the 4.0 storage flip and the estimand self-description hook.
- Diagnostic: marker base on the 17-member diagnostic result roster (Bacon,
  RDPlot, HonestDiD/Sensitivity, pre-trends/power, placebo, HAD pretests,
  DiagnosticReport results). Marked results carry no inference row.
- EventStudyResults: the unified per-event-time representation (columnar,
  pinned to_dataframe schema, explicit is_reference marking - no count
  sentinels). build_event_study_surface() adapts all 14 event-study
  producers; public exposure rides aggregate(type="event_study") in a later
  Phase 2 PR. Producer-aware reference resolution (a named reference is
  MARKED if materialized (Spillover) or SYNTHESIZED if omitted-but-observed
  (SunAbraham, gated on reference_observed); a zero-count sentinel row is a
  reference iff its effect is exactly 0, else a preserved non-estimable row).
  Multiple and zero reference rows both legal; requested-empty vs absent
  distinguished; JSON-safe calendar labels; vcov round-trips; caller arrays
  never mutated.

Also: seven estimator results classes gain to_dict() (CS, Stacked,
Continuous, SunAbraham, Wooldridge, dCDH, Bacon); DiagnosticReportResults
gains summary()/to_dict()/to_dataframe() (moved from the builder, which now
delegates); PreTrendsPowerCurve gains summary(); SunAbrahamResults persists
reference_period + reference_observed.

Consumer propagation (M-091): BusinessReport, practitioner_next_steps, and
DiagnosticReport route diagnostics by the marker instead of by result-class
name. Passing a marked non-Bacon diagnostic as a primary estimator input now
raises TypeError (previously: name-only Bacon special-case in BR; silent
zero-check report in DR). Estimator numerical output is unchanged.

Ledger: M-091 -> done; M-092 (unified event-study surface, born done) and
M-093 (4.0 sentinel retirement, phase 5) added. Matrix floor 75->77;
snapshot extended by (92,93). Spec sec 5/11 citations; doc-deps entry for the
new module; api/results.rst + api/index.rst paired autoclass/autosummary;
M-090 code_refs += tests/test_docs_ia.py.

Tests: tests/test_diagnostic_marker.py, tests/test_event_study_surface.py,
tests/test_results_serialization.py (all analytical small fits).

Follow-up tracked in TODO.md: persist full event-study VCV + per-producer df
round-trip for StackedDiD/TwoStageDiD (additive estimator-container work).
@igerber
igerber force-pushed the feat/v4-phase2a-results-foundation branch from 578e958 to 6d1daa3 Compare July 19, 2026 23:38
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 6d1daa3bbc9338d71772116831e5993116814d42


Overall assessment

⚠️ Needs changes

Executive summary

  • One newly identified P1: dCDH switcher counts are mislabeled as observation counts.
  • Prior Sun–Abraham reference handling and array-ownership findings are resolved.
  • No estimator, weighting, ATT, or SE regression found.
  • VCV/df omissions remain tracked and non-blocking.

Methodology

[Newly identified] P1 — dCDH count units are mislabeled

Impact: EventStudyResults.n_kind="obs" declares n to be observations (diff_diff/results_base.py:L164-L170,L846-L856), but dCDH stores N_l, the number of eligible switcher groups, under its legacy "n_obs" key (diff_diff/chaisemartin_dhaultfoeuille.py:L2352-L2378; docs/methodology/REGISTRY.md:L925-L928,L984). On the legacy path it represents switching cells (diff_diff/chaisemartin_dhaultfoeuille_results.py:L329-L340). Downstream displays or sample-size logic will silently interpret the wrong unit.

Concrete fix: Set producer-accurate metadata—e.g. "groups" for L_max >= 1 and "switcher_cells" (documented) or None for L_max is None.

The prior Sun–Abraham P1 is resolved: synthesis is gated by genuinely observed support (diff_diff/sun_abraham.py:L860-L885, diff_diff/results_base.py:L567-L582), consistent with the registry’s e=-1-anticipation convention.

Code Quality

No additional findings. Caller-owned arrays are copied before normalization (diff_diff/results_base.py:L242-L251).

Performance

No findings.

Maintainability

No additional findings.

Tech Debt

P3 — Tracked VCV/df omissions

Impact: Some producers cannot yet round-trip full VCV or inference degrees of freedom.

Concrete fix: No approval action required; tracked in TODO.md:L26.

Security

No findings or exposed secrets identified.

Documentation/Tests

The dCDH adapter needs regression tests asserting both n and n_kind for multi-horizon and legacy fits. Existing tests only pin LPDiD’s count kind.

Focused tests could not run because pytest and NumPy are unavailable. All 92 Python sources parsed successfully.

Path to Approval

  1. Correct dCDH n_kind, document any new count-kind value, and add tests covering L_max >= 1 and L_max is None.

dCDH stores N_l (eligible switcher GROUPS per horizon, and N_pl_l for
placebo horizons) under its legacy "n_obs" inner key - a group-level count,
NOT observations (chaisemartin_dhaultfoeuille.py:2378). The event-study
adapter hardcoded n_kind="obs", so a consumer doing sample-size logic would
misread switcher groups as observations. Label it "groups" and broaden the
n_kind field doc (groups = cohorts for CS/SA, eligible switcher groups for
dCDH). Added regression tests asserting n/n_kind for the multi-horizon and
legacy single-horizon dCDH paths.
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 3a4ee4dc6f771789b0c50301367a8dc235defb16


Overall assessment

⚠️ Needs changes

Executive summary

  • The prior dCDH P1 is only partially fixed.
  • L_max >= 1 correctly labels N_l as "groups".
  • L_max=None incorrectly labels switching (g,t) cells as "groups".
  • No estimator, inference, performance, or security regression found.
  • Static parsing passed; runtime tests could not run because NumPy, pandas, and pytest are unavailable.

Methodology

P1 — Legacy dCDH count units remain mislabeled

Impact: _from_dcdh() always sets n_kind="groups" (diff_diff/results_base.py:L858-L861). This is correct for multi-horizon N_l, but not for L_max=None, where "n_obs" contains N_S, explicitly defined as the number of switching (g,t) cells (docs/methodology/REGISTRY.md:L887-L893; diff_diff/chaisemartin_dhaultfoeuille_results.py:L333-L339; diff_diff/chaisemartin_dhaultfoeuille.py:L3294-L3319). With drop_larger_lower=False, one group may contribute multiple switching cells, so downstream code can silently mistake this for a unique-group count.

Concrete fix: Set n_kind="groups" only when results.L_max >= 1. For L_max is None, use a documented "switcher_cells" kind or None.

Code Quality

No additional findings.

Performance

No findings.

Maintainability

No findings.

Tech Debt

P3 — Tracked VCV/df omissions

Impact: Some producers cannot yet round-trip full event-study VCV or inference degrees of freedom.

Concrete fix: No approval action required; tracked in TODO.md:L26.

Security

No findings or exposed secrets identified.

Documentation/Tests

The legacy regression test asserts the incorrect "groups" expectation using a fake object without L_max (tests/test_event_study_surface.py:L526-L545).

Concrete fix: Replace it with an L_max=None case asserting "switcher_cells" or None, preferably using a multi-switch fit with drop_larger_lower=False.

Path to Approval

  1. Make dCDH n_kind conditional on L_max and document the legacy count kind.
  2. Update the legacy-path regression test to verify switching-cell semantics.

The dCDH count under the legacy "n_obs" key is L_max-dependent (never
observations): with L_max >= 1 it is N_l = eligible switcher GROUPS per
horizon; with L_max is None it is N_S = switching (g,t) CELLS, where one
group can contribute several (chaisemartin_dhaultfoeuille_results.py:333-339).
Labeling both "groups" was wrong for the legacy path. n_kind is now "groups"
when L_max >= 1 and "switcher_cells" (new documented vocabulary value) when
L_max is None. Regression test asserts the legacy path yields switcher_cells.
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: c59fd7bb0a2e9ab8c45d8e80a10536fbc387ed00


Overall assessment

Looks good

Executive summary

  • Prior dCDH P1 is resolved: count units now depend on L_max.
  • Event-study adapters preserve native estimates, inference, references, and covariance provenance.
  • Empty results and NaN inference paths are handled consistently.
  • Remaining VCV/df work is tracked in TODO.md.
  • Static parsing passed; runtime tests were unavailable because NumPy and pytest are not installed.

Methodology

P3 — Prior dCDH finding resolved

Impact: None. Multi-horizon N_l is labeled "groups" while legacy N_S is labeled "switcher_cells", matching the Registry’s group-versus-(g,t)-cell semantics (diff_diff/results_base.py:L850-L869, docs/methodology/REGISTRY.md:L887-L893).

Concrete fix: None required. Regression coverage exists at tests/test_event_study_surface.py:L526-L547.

No estimator equations, weighting, identification assumptions, or variance calculations were changed. The adapters for all 14 event-study producers copy their native statistical outputs.

Code Quality

No findings.

Performance

No findings.

Maintainability

No findings.

Tech Debt

P3 — Tracked VCV/df round-trip limitations

Impact: Some producers expose only marginal SEs or omit producer-specific inference degrees of freedom.

Concrete fix: No approval action required; follow-up work is tracked in TODO.md:L26.

Security

No findings or exposed secrets identified.

Documentation/Tests

No findings. The added tests cover the diagnostic roster, reference normalization, empty surfaces, NaN propagation, covariance serialization, and all 14 producers.

Static parsing succeeded for 243 Python files. Runtime tests could not run because the review environment lacks NumPy and pytest.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Jul 20, 2026
@igerber
igerber merged commit d0b33ce into main Jul 20, 2026
39 of 43 checks passed
@igerber
igerber deleted the feat/v4-phase2a-results-foundation branch July 20, 2026 11:21
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