From f6408d9d82475f14ae5a552da613387755ebd60a Mon Sep 17 00:00:00 2001 From: igerber Date: Wed, 21 Oct 2026 06:34:09 -0400 Subject: [PATCH] feat(v4): PR 3b of the 4.0 cut - remove the fit-time aggregate=/balance_e= keywords on CS, EfficientDiD, StackedDiD, dCDH and HAD Spec section 9 item 3, sub-item 3b (3a landed the tests-first migration in #863; 3c carries the ImputationDiD/TwoStageDiD/ContinuousDiD removals and the bootstrapped-fit relay). Ledger rows M-020, M-023, M-024, M-026, M-027, M-117, M-120 and M-139 flip to removed. - diff_diff: CallawaySantAnna / EfficientDiD / StackedDiD / ChaisemartinDHaultfoeuille / HeterogeneousAdoptionDiD lose the fit-time aggregate= (and balance_e=) parameters; did_had_pretest_workflow loses aggregate= (the HADPretestReport.aggregate output field survives). CS and EfficientDiD compute no event-study/group surface at fit, so their legacy fields are None after every fit; the post-fit replay is the only bootstrap route. StackedDiD's fit-time group/all ValueErrors and the HAD _VALID_AGGREGATES sets go with the keywords. honest_did / pretrends / plot_event_study / _reporting_helpers / diagnostic_report messages and the four llms*.txt guides stop steering users to the removed route; _deprecation.py records that warn_deprecated_kwarg has no library caller. - docs: ledger rows + M-093 note (CS/EDiD populate the legacy fields on no fit), spec section 6 amendment + "(SHIPPED, PR 3b)" marker, REGISTRY CS / EDiD / Stacked / HAD notes, REPORTING, migration guide (slot shifts), API/perf docs, tutorial 02 comment. - tests: test_aggregate_contract retires the five Test*Shim classes, the fit-time fixtures and the inertness comparisons for Test*Removal pins (TypeError, signature, positional-slot binding); the estimator suites take their removal pins; test_guides pins the signature blocks without fit-time lines; naming-guard allowlist entries for llms.txt / llms-full.txt; _capture_v4_aggregate_oracles.py marked historical (runnable only at the recorded captured_at_head); warn_deprecated_kwarg gets a direct unit pin. --- benchmarks/python/benchmark_callaway.py | 2 +- benchmarks/python/benchmark_stacked_did.py | 3 +- benchmarks/speed_review/bench_callaway.py | 2 +- .../speed_review/bench_solve_ols_fastpath.py | 2 +- benchmarks/speed_review/validate_results.py | 2 +- ...1-v4-pr3b-aggregate-removals-mechanical.md | 55 ++ diff_diff/_deprecation.py | 11 +- diff_diff/_reporting_helpers.py | 16 +- diff_diff/chaisemartin_dhaultfoeuille.py | 45 +- diff_diff/diagnostic_report.py | 8 +- diff_diff/efficient_did.py | 163 +---- diff_diff/efficient_did_aggregation.py | 5 +- diff_diff/efficient_did_results.py | 24 +- diff_diff/guides/llms-autonomous.txt | 2 +- diff_diff/guides/llms-full.txt | 52 +- diff_diff/guides/llms-practitioner.txt | 19 +- diff_diff/guides/llms.txt | 2 +- diff_diff/had.py | 120 ++-- diff_diff/had_pretests.py | 107 ++-- diff_diff/honest_did.py | 4 +- diff_diff/pretrends.py | 5 +- diff_diff/stacked_did.py | 66 +- diff_diff/staggered.py | 179 +----- diff_diff/staggered_results.py | 8 +- diff_diff/visualization/_event_study.py | 3 +- docs/api/had.rst | 4 +- docs/methodology/REGISTRY.md | 38 +- docs/methodology/REPORTING.md | 5 +- docs/migration-4.0.md | 14 +- docs/performance-plan.md | 3 +- docs/performance-scenarios.md | 14 +- docs/troubleshooting.rst | 2 +- docs/tutorials/02_staggered_did.ipynb | 2 +- docs/v4-deprecations.yaml | 34 +- docs/v4-design.md | 8 +- tests/_capture_v4_aggregate_oracles.py | 16 +- tests/test_aggregate_contract.py | 567 ++++++------------ tests/test_bootstrap_chunking.py | 6 +- tests/test_business_report.py | 2 +- tests/test_chaisemartin_dhaultfoeuille.py | 39 +- tests/test_csdid_ported.py | 2 +- tests/test_efficient_did.py | 12 +- tests/test_event_study_consumers.py | 2 +- tests/test_event_study_surface.py | 22 +- tests/test_guides.py | 75 ++- tests/test_had.py | 125 ++-- tests/test_had_dual_knob_deprecation.py | 11 +- tests/test_had_pretests.py | 30 +- tests/test_honest_did.py | 2 +- tests/test_methodology_callaway.py | 4 +- tests/test_naming_guard.py | 40 +- tests/test_stacked_did.py | 79 +-- tests/test_staggered.py | 4 +- tests/test_survey_phase7a.py | 2 +- tests/test_v4_rename_mechanical.py | 9 + tests/test_variance_conventions.py | 2 +- tests/test_visualization.py | 6 +- 57 files changed, 782 insertions(+), 1304 deletions(-) create mode 100644 changelog.d/20261021-v4-pr3b-aggregate-removals-mechanical.md diff --git a/benchmarks/python/benchmark_callaway.py b/benchmarks/python/benchmark_callaway.py index 98072261f..38ef8536b 100644 --- a/benchmarks/python/benchmark_callaway.py +++ b/benchmarks/python/benchmark_callaway.py @@ -124,7 +124,7 @@ def main(): ) # Post-fit aggregations stay inside the timed region: the R `aggte` # yardstick is fit + simple/dynamic/group aggregation (the 3.x - # fit(aggregate="all") route, deprecated since 3.9 and removed at the 4.0 cut). + # fit(aggregate="all") route, removed at 4.0). es = results.aggregate("event_study") grp = results.aggregate("group") diff --git a/benchmarks/python/benchmark_stacked_did.py b/benchmarks/python/benchmark_stacked_did.py index e070005ef..cd9cd16a7 100644 --- a/benchmarks/python/benchmark_stacked_did.py +++ b/benchmarks/python/benchmark_stacked_did.py @@ -96,8 +96,7 @@ def main(): ) # The event-study surface is always computed at fit (3.9, row M-024); - # the deprecated aggregate= kwarg would emit a FutureWarning inside - # the timed block and contaminate the measurement. + # fit() takes no aggregate= kwarg since 4.0. with Timer() as estimation_timer: results = est.fit( df, diff --git a/benchmarks/speed_review/bench_callaway.py b/benchmarks/speed_review/bench_callaway.py index 9fe6b0806..55a32a7d2 100644 --- a/benchmarks/speed_review/bench_callaway.py +++ b/benchmarks/speed_review/bench_callaway.py @@ -66,7 +66,7 @@ def bench_fit(n_units, n_bootstrap=0, covariates=None, n_cohorts=5, n_runs=3, covariates=covariates, ) # Keep "fit + aggregations" as the measured work (the 3.x - # fit(aggregate="all") route, deprecated since 3.9 and removed at the 4.0 cut). + # fit(aggregate="all") route, removed at 4.0). res.aggregate("event_study") res.aggregate("group") elapsed = time.perf_counter() - start diff --git a/benchmarks/speed_review/bench_solve_ols_fastpath.py b/benchmarks/speed_review/bench_solve_ols_fastpath.py index 94437eb71..1376daca9 100644 --- a/benchmarks/speed_review/bench_solve_ols_fastpath.py +++ b/benchmarks/speed_review/bench_solve_ols_fastpath.py @@ -126,7 +126,7 @@ def fit(d): covariates=covs, ) # fit + aggregations is the measured work (the 3.x - # fit(aggregate="all") route, deprecated since 3.9 and removed at the 4.0 cut). + # fit(aggregate="all") route, removed at 4.0). res.aggregate("event_study") res.aggregate("group") return float(res.overall_att), float(res.overall_se) diff --git a/benchmarks/speed_review/validate_results.py b/benchmarks/speed_review/validate_results.py index 684ec19e3..7965efc6e 100644 --- a/benchmarks/speed_review/validate_results.py +++ b/benchmarks/speed_review/validate_results.py @@ -65,7 +65,7 @@ def run_estimator(df, estimation_method="reg", covariates=None, control_group="n first_treat="first_treat", covariates=covariates, ) - # Post-fit containers (the 3.x fit(aggregate="all") route is deprecated and goes at the 4.0 cut); + # Post-fit containers (the 3.x fit(aggregate="all") route, removed at 4.0); # the fit is bootstrapped, so each call replays the fit-time bootstrap. es_container = results.aggregate("event_study") grp_container = results.aggregate("group") diff --git a/changelog.d/20261021-v4-pr3b-aggregate-removals-mechanical.md b/changelog.d/20261021-v4-pr3b-aggregate-removals-mechanical.md new file mode 100644 index 000000000..d559f3736 --- /dev/null +++ b/changelog.d/20261021-v4-pr3b-aggregate-removals-mechanical.md @@ -0,0 +1,55 @@ +### Removed +- **The fit-time `aggregate=` / `balance_e=` keywords are gone on the estimators whose post-fit + route already existed (ledger rows flipped to `removed`).** `fit(aggregate=)` is removed from + `CallawaySantAnna` (`M-020`), `EfficientDiD` (`M-023`), `StackedDiD` (`M-024`), + `ChaisemartinDHaultfoeuille` (`M-026`) and `HeterogeneousAdoptionDiD` (`M-027`), + `fit(balance_e=)` from `CallawaySantAnna` (`M-117`) and `EfficientDiD` (`M-120`), and + `aggregate=` from `did_had_pretest_workflow` (`M-139`; the `HADPretestReport.aggregate` + output field survives). The successor is post-fit `results.aggregate(type, balance_e=)`; HAD + selects its mode from the panel shape. With the keywords go StackedDiD's fit-time + `group`/`all`/unknown-value `ValueError`s (`aggregate('group')` already fails closed in the + mixin) and the private `_VALID_AGGREGATES` sets in `had.py` / `had_pretests.py` + (`_infer_aggregate_mode` is the sole selector). `ImputationDiD`, `TwoStageDiD` and + `ContinuousDiD` keep their deprecated keywords until the next cut PR adds the + bootstrapped-fit relay. +- **CallawaySantAnna and EfficientDiD compute no event-study/group surface at fit.** Their + legacy `event_study_effects` / `group_effects` fields - and CS's `cband_crit_value`, + `event_study_vcov`, `event_study_vcov_index`, `event_study_df` - are `None` after EVERY fit; + the post-fit replay (`aggregate('event_study'|'group', balance_e=)`) is the only bootstrap + route and is unchanged. + +### Breaking Changes +- Passing `aggregate=` or `balance_e=` to `CallawaySantAnna.fit`, `EfficientDiD.fit`, + `StackedDiD.fit`, `ChaisemartinDHaultfoeuille.fit` or `HeterogeneousAdoptionDiD.fit` (or + `aggregate=` to `did_had_pretest_workflow`) now raises Python's native + `TypeError: ... unexpected keyword argument`. `TripleDifference`'s staggered-mode + `aggregate=`/`balance_e=` are untouched (`M-140`/`M-141`, canonical until the DDD container + unification). +- **Positional slots after the removed parameters shift left.** `CallawaySantAnna`: + `survey_design` 9 -> 7; `EfficientDiD`: `survey_design` 9 -> 7, `store_eif` 10 -> 8; + `StackedDiD` (`aggregate` sat before them): `population`, `survey_design`, `covariates` + 7/8/9 -> 6/7/8; `ChaisemartinDHaultfoeuille`: `L_max`, `covariates`, `trends_linear`, + `trends_nonparam`, `honest_did`, `heterogeneity`, `design2`, `survey_design` each move one + slot left (7..14 -> 6..13). `HeterogeneousAdoptionDiD.fit` shifts nothing (everything after + the removed slot is keyword-only; a seventh positional now raises). Pass these by keyword. +- `CallawaySantAnnaResults.to_dataframe(level="event_study"|"group")` and the + `EfficientDiDResults` twins raise on every fresh fit (the legacy fields are always `None`); + use `results.aggregate(...).to_dataframe()`. The column-schema rewire of these levels onto the + container is `M-093`'s (PR 8). + +### Changed +- Runtime messages, the four `llms*.txt` guides, the API docs, the migration guide and the + benchmarks no longer steer users to the fit-time route on these five estimators; + `CallawaySantAnna`'s class docstring, `plot_event_study`, `honest_did` / `pretrends` and the + results-class "not stored" messages point at `results.aggregate('event_study')`. + +### Internal +- `tests/test_aggregate_contract.py`: the `Test*Shim` classes, fit-time fixtures and + inertness comparisons for the five estimators are retired in favour of `Test*Removal` + pins (TypeError, signature, positional-slot binding); the committed oracle fixture is the + numerical reference. `tests/_capture_v4_aggregate_oracles.py` is marked historical (it + drives the removed route; runnable only at the recorded `captured_at_head`). Naming-guard + allowlist entries for the `llms.txt` / `llms-full.txt` canonical post-fit `aggregate` + vocabulary. Ledger row `M-093` records that CS/EfficientDiD populate the legacy fields on + no fit. `warn_deprecated_kwarg` loses its last library caller and is retained (spec section + 8 rule 11) with a direct unit pin. diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py index 1894feed0..e061121f6 100644 --- a/diff_diff/_deprecation.py +++ b/diff_diff/_deprecation.py @@ -16,9 +16,14 @@ default. ``resolve_renamed_kwarg`` maps old to new with a single ``FutureWarning``, rejects both-supplied calls loudly, and returns the caller-declared default when neither is passed. -- Dropped parameter (no successor name, e.g. the fit-time ``aggregate=`` - override): the parameter keeps its position with a sentinel default; the - owner calls ``warn_deprecated_kwarg`` when a value was actually supplied. +- Dropped parameter (no successor name): the parameter keeps its position + with a sentinel default; the owner calls ``warn_deprecated_kwarg`` when a + value was actually supplied. (The 3.9 fit-time ``aggregate=`` shims on + CallawaySantAnna, EfficientDiD, StackedDiD, ChaisemartinDHaultfoeuille and + HeterogeneousAdoptionDiD were this shape; they were deleted at the 4.0 cut + (PR 3b) and ImputationDiD / TwoStageDiD / ContinuousDiD follow in PR 3c. + ``warn_deprecated_kwarg`` has no library caller any more (dCDH was the + last) and is retained per section 8 rule 11 for the 5.0 window.) - Renamed results field: the dataclass field takes the new name and ``deprecated_field_property`` builds the read-only warning alias under the old name (a plain class attribute, so it is a descriptor but NOT a diff --git a/diff_diff/_reporting_helpers.py b/diff_diff/_reporting_helpers.py index 9bdc69b84..ab55f42de 100644 --- a/diff_diff/_reporting_helpers.py +++ b/diff_diff/_reporting_helpers.py @@ -56,11 +56,11 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: disambiguate via the event-study or group aggregate if you need the horizon / group target. - ``CallawaySantAnna``: ``overall_att`` is cohort-size-weighted - across post-treatment ``ATT(g, t)`` cells regardless of the - fit-time ``aggregate`` kwarg. The event-study / group tables are - produced post-fit via ``results.aggregate('event_study'/'group')`` - (the deprecated fit-time ``aggregate=`` kwarg populates the legacy - ``event_study_effects`` / ``group_effects`` fields until 4.0). + across post-treatment ``ATT(g, t)`` cells regardless of any + aggregation. The event-study / group tables are produced post-fit + via ``results.aggregate('event_study'/'group')``; the legacy + ``event_study_effects`` / ``group_effects`` fields are None on + every CallawaySantAnna fit since 4.0. - ``ContinuousDiD``: the regime (PT vs. SPT) is a user-level assumption, not a library setting. The ``definition`` names both regime readings (``ATT^loc`` under PT, @@ -140,9 +140,9 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "``overall_att`` is the simple-aggregation headline regardless " "of aggregation choices; event-study and group tables are " "produced post-fit via " - "``results.aggregate('event_study'/'group')`` (the deprecated " - "fit-time ``aggregate=`` kwarg populates the legacy " - "``event_study_effects`` / ``group_effects`` fields until 4.0)." + "``results.aggregate('event_study'/'group')`` (the legacy " + "``event_study_effects`` / ``group_effects`` fields are None " + "on every CallawaySantAnna fit since 4.0)." ), "aggregation": "simple", "headline_attribute": "overall_att", diff --git a/diff_diff/chaisemartin_dhaultfoeuille.py b/diff_diff/chaisemartin_dhaultfoeuille.py index dd57fee23..8594fdecb 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille.py +++ b/diff_diff/chaisemartin_dhaultfoeuille.py @@ -37,7 +37,6 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED, warn_deprecated_kwarg from diff_diff.chaisemartin_dhaultfoeuille_bootstrap import ( ChaisemartinDHaultfoeuilleBootstrapMixin, ) @@ -401,8 +400,8 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin, BaseE - TWFE decomposition diagnostic from Theorem 1 of AER 2020 Aggregation is a post-fit step: ``results.aggregate('event_study')`` / - ``results.aggregate('simple')`` (the deprecated fit-time ``aggregate=`` - never computed anything and now warns; row M-026). + ``results.aggregate('simple')``; ``fit()`` takes no aggregation + keyword. Parameters ---------- @@ -878,7 +877,6 @@ def fit( time: str, treatment: str, # ---------- forward-compat parameters ---------- - aggregate: Any = NOT_SUPPLIED, L_max: Optional[int] = None, covariates: Optional[List[str]] = None, trends_linear: Optional[bool] = None, @@ -914,15 +912,6 @@ def fit( within each ``(unit, time)`` cell. Both binary ``{0, 1}`` and non-binary (ordinal or continuous) treatment are supported. Non-binary treatment requires ``L_max >= 1``. - aggregate : str, optional - DEPRECATED since 3.9, removed in 4.0 (ledger row M-026). - Passing it emits a ``FutureWarning``. The parameter never - computed aggregations here (it was reserved and any non-None - value has always raised); aggregation is a POST-FIT step: - ``results.aggregate('event_study')`` for the multi-horizon - surface, ``results.aggregate('simple')`` for the overall - estimand. A non-None value now raises ``ValueError`` pointing - at that post-fit route. L_max : int, optional Maximum event-study horizon. When set, computes ``DID_l`` for ``l = 1, ..., L_max`` using the per-group building block @@ -1063,28 +1052,8 @@ def fit( # ------------------------------------------------------------------ # Step 2: Forward-compat gates # ------------------------------------------------------------------ - # M-026: fit(aggregate=) is deprecated - the aggregation entry point - # is post-fit (results.aggregate()). The param never computed - # anything here (any non-None value has always raised), so the shim - # warns on supply and rejects non-None values with a pointer at the - # post-fit route. - if aggregate is not NOT_SUPPLIED: - warn_deprecated_kwarg( - "ChaisemartinDHaultfoeuille.fit", - "aggregate", - "aggregation is a post-fit step: " - "results.aggregate('event_study') / results.aggregate('simple')", - ) - if aggregate is not None: - raise ValueError( - f"fit(aggregate={aggregate!r}) never computed aggregations " - "on ChaisemartinDHaultfoeuille (the parameter was reserved " - "and always raised). Aggregate post-fit instead: " - "results.aggregate('event_study') for the multi-horizon " - "surface (Phase-1 fits without L_max return the 2-row " - "l=1 view), or results.aggregate('simple') for the " - "overall estimand." - ) + # Aggregation is a post-fit step (results.aggregate('event_study') / + # results.aggregate('simple')); fit() takes no aggregation keyword. _check_forward_compat_gates( L_max=L_max, controls=controls, @@ -4395,10 +4364,8 @@ def _check_forward_compat_gates( """Raise ``NotImplementedError`` for any non-default Phase 3 parameter. Phase 2 parameters (``L_max``) are validated inline in ``fit()`` - after period detection. The deprecated ``aggregate`` parameter is - resolved in ``fit()`` itself (row M-026: FutureWarning on supply, - ValueError on any non-None value, pointing at the post-fit - ``results.aggregate()`` route). + after period detection. Aggregation is a post-fit step + (``results.aggregate()``); ``fit()`` takes no aggregation keyword. """ # L_max is validated inline in fit() after period detection (needs # the period count). Not gated here. diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index b74d71aac..790d8f803 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -807,7 +807,7 @@ def _resolve_event_study_surface( The raw ``event_study_effects`` field always takes precedence when it is not ``None`` — including the requested-but-empty ``{}`` sentinel, - which encodes fit-time configuration (e.g. an EfficientDiD + which encodes fit-time configuration (e.g. an ImputationDiD ``balance_e=`` that emptied the window) that a re-derivation without those settings would silently overturn. Derivation failures (bootstrapped fits, missing kits) are cached as skip reasons, never @@ -1006,9 +1006,9 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: "per-event-time direct-effect output." ) # StackedDiD (3.9, row M-024): the event-study surface - # is ALWAYS computed, so ``aggregate='event_study'`` is - # inert (and deprecated - it now warns). Zero estimated - # pre-periods here means the event window has none: + # is ALWAYS computed at fit (no aggregation keyword + # exists since 4.0). Zero estimated pre-periods here + # means the event window has none: # the default ``kappa_pre=1`` grid minus the reference # leaves no pre-period columns. Estimator-accurate # remediation, per the SpilloverDiD precedent above. diff --git a/diff_diff/efficient_did.py b/diff_diff/efficient_did.py index 65bd933e4..339f3bb9b 100644 --- a/diff_diff/efficient_did.py +++ b/diff_diff/efficient_did.py @@ -29,12 +29,7 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.aggregation import AggregationKit, BootstrapReplaySpec -from diff_diff.bootstrap_utils import ( - apply_bootstrap_event_study_overrides, - apply_bootstrap_group_overrides, -) from diff_diff.efficient_did_aggregation import ( _cluster_aggregate, _compute_se_from_eif, @@ -131,13 +126,13 @@ def _build_edid_aggregation_kit( per-(g,t) EIF dict, O(n_units x n_gt) (full enumeration in the ``docs/methodology/REGISTRY.md`` EfficientDiD M-023 Note). - ``df_survey`` MUST be the post-overall snapshot captured in ``fit()`` - immediately after the overall inference and before the ES/group gates — - NOT a read of ``estimator._survey_df`` at kit-build time, which on a - deprecated ``fit(aggregate="group"/"all")`` replicate fit is the - post-group value (``_compute_survey_eif_se`` can set it to ``None`` on - a degenerate replicate design). The snapshot is the exact value every - fit-time aggregation seeded from, so post-fit recompute replays it. + ``df_survey`` is the post-overall snapshot captured in ``fit()`` + immediately after the overall inference - the kit's named seed for + every post-fit recompute (``results.aggregate('event_study' | + 'group')``). Since 4.0 no fit-time aggregation runs after it, so the + snapshot equals a fit-end read of ``estimator._survey_df``; it is + still captured by name so the post-fit route seeds from an explicit + value rather than from estimator state. """ if not eif_by_gt: # Unreachable after fit()'s empty-effects raise; kept for the CS @@ -498,8 +493,6 @@ def fit( time: str, first_treat: str, covariates: Optional[List[str]] = None, - aggregate: Any = NOT_SUPPLIED, - balance_e: Any = NOT_SUPPLIED, survey_design: Optional[Any] = None, store_eif: bool = False, ) -> EfficientDiDResults: @@ -522,34 +515,6 @@ def fit( Column names for time-invariant unit-level covariates. When provided, uses the doubly robust path (outcome regression + propensity score ratios). - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0, row M-023): supplying any - value (``None`` included) emits a FutureWarning; the - deprecated path still computes and stores the requested - surfaces exactly as before (``None``, ``"simple"``, - ``"event_study"``, ``"group"``, or ``"all"``; unknown strings - act like ``None``, unchanged). Aggregate as a post-fit step - instead: ``results.aggregate('event_study')`` / - ``.aggregate('group')`` / ``.aggregate('simple')`` / - ``.aggregate('total')``. On - bootstrapped fits (``n_bootstrap > 0``) the post-fit - RECOMPUTE levels (``'event_study'``/``'group'``) REPLAY the - fit-time multiplier bootstrap from the kit-retained RNG - state (percentile inference, allclose to a fit-time - aggregation; no refit needed), while ``aggregate('simple')`` - and, where supported, ``aggregate('total')`` relay the - stored bootstrap inference (the per-level policy converged - with row M-027). - balance_e : int, optional - DEPRECATED (3.9, removed in 4.0, row M-120): moves onto - post-fit ``aggregate()`` — - ``results.aggregate('event_study', balance_e=2)``. EDiD's - balance rule is the ANCHOR-HORIZON rule (keep cohorts with a - finite effect at the anchor horizon), the same rule shape - CallawaySantAnna uses — with one keying-granularity - difference: EDiD anchors on the ``int(t - g)`` bucket while - CS keys raw ``t - g`` (identical on integer-period panels; - see the REGISTRY truncation Note). survey_design : SurveyDesign, optional Survey design specification for design-based inference. Applies survey weights to all means, covariances, and cohort @@ -566,6 +531,16 @@ def fit( Returns ------- EfficientDiDResults + Aggregations are a post-fit step: ``results.aggregate('event_study' + | 'group' | 'simple' | 'total', balance_e=...)``. On bootstrapped + fits (``n_bootstrap > 0``) the recompute levels replay the fit-time + multiplier bootstrap from the kit-retained RNG state. EDiD's + balance rule is the ANCHOR-HORIZON rule (keep cohorts with a + finite effect at the anchor horizon), the same rule shape + CallawaySantAnna uses - with one keying-granularity difference: + EDiD anchors on the ``int(t - g)`` bucket while CS keys raw + ``t - g`` (identical on integer-period panels; see the REGISTRY + truncation Note). Raises ------ @@ -573,35 +548,6 @@ def fit( Missing columns, unbalanced panel, non-absorbing treatment, or PT-Post without a never-treated group. """ - # M-023/M-120 deprecation shim (CS-style joint warning): a plain - # fit() never warns; supplying EITHER param with ANY value (None - # included) warns once, then the legacy routing below runs - # unchanged - the deprecated path returns exactly the numbers it - # always did (no new value validation; unknown strings still act - # like None). The post-fit successor validates its own vocabulary. - _deprecated_passed = [ - n - for n, v in (("aggregate", aggregate), ("balance_e", balance_e)) - if v is not NOT_SUPPLIED - ] - if _deprecated_passed: - _args = " / ".join(f"{n}=" for n in _deprecated_passed) - warnings.warn( - f"EfficientDiD.fit({_args}) is deprecated and will be " - "removed in 4.0. Fit once, then aggregate as a post-fit " - "step: results = EfficientDiD().fit(...); " - "results.aggregate('event_study') / .aggregate('group') / " - ".aggregate('simple') / .aggregate('total'). balance_e moves onto aggregate() " - "alongside it: results.aggregate('event_study', " - "balance_e=2).", - FutureWarning, - stacklevel=2, - ) - if aggregate is NOT_SUPPLIED: - aggregate = None - if balance_e is NOT_SUPPLIED: - balance_e = None - self._validate_params() if self.cluster is not None and survey_design is not None: @@ -1306,44 +1252,15 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any overall_t, overall_p, overall_ci = safe_inference( overall_att, overall_se, alpha=self.alpha, df=self._survey_df ) - # M-023 kit snapshot: the post-overall ``_survey_df`` is the exact - # value fit-time ES/group aggregation seeds from (the gates below run - # next), and post-fit aggregate() must replay from the same seed. - # Captured HERE, not at fit end: ``_aggregate_by_group`` can mutate - # ``self._survey_df`` through ``_compute_survey_eif_se`` on degenerate - # replicate designs, so a fit-end read on a deprecated - # ``fit(aggregate="group"/"all")`` would seed recompute from the - # post-group value and break post-fit/fit-time inertness. + # M-023 kit snapshot: the post-overall ``_survey_df`` is the kit's + # named seed for every post-fit aggregate() recompute. Since 4.0 no + # fit-time ES/group aggregation runs after it (the legacy + # ``event_study_effects`` / ``group_effects`` fields are None on + # every fit; ``results.aggregate('event_study' | 'group')`` is the + # surface), so the snapshot equals a fit-end read - it is kept by + # name so the post-fit route seeds from an explicit value. _survey_df_post_overall = self._survey_df - event_study_effects = None - group_effects = None - - if aggregate in ("event_study", "all"): - event_study_effects = self._aggregate_event_study( - group_time_effects, - eif_by_gt, - n_units, - cohort_fractions, - treatment_groups, - time_periods, - balance_e, - unit_cohorts=unit_cohorts, - cluster_indices=unit_cluster_indices, - n_clusters=n_clusters, - ) - if aggregate in ("group", "all"): - group_effects = self._aggregate_by_group( - group_time_effects, - eif_by_gt, - n_units, - cohort_fractions, - treatment_groups, - unit_cohorts=unit_cohorts, - cluster_indices=unit_cluster_indices, - n_clusters=n_clusters, - ) - # ----- Bootstrap ----- # Reject replicate-weight designs for bootstrap — replicate variance # is an analytical alternative, not compatible with bootstrap @@ -1363,8 +1280,8 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any group_time_effects=group_time_effects, eif_by_gt=eif_by_gt, n_units=n_units, - aggregate=aggregate, - balance_e=balance_e, + aggregate=None, + balance_e=None, treatment_groups=treatment_groups, cohort_fractions=cohort_fractions, cluster_indices=unit_cluster_indices, @@ -1387,15 +1304,6 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any se = float(group_time_effects[gt]["se"]) group_time_effects[gt]["t_stat"] = safe_inference(eff, se, alpha=self.alpha)[0] - # ES/group percentile overrides via the shared appliers (the same - # implementations the post-fit aggregate() replay runs — one - # code path, no fit-vs-replay drift). The appliers carry the - # availability guards and the group df_used clearing internally. - apply_bootstrap_event_study_overrides( - event_study_effects, bootstrap_results, self.alpha - ) - apply_bootstrap_group_overrides(group_effects, bootstrap_results, self.alpha) - # ----- Build results ----- # Raw (pre-normalization) unit weights for the metadata recompute: # compute_survey_metadata expects the ORIGINAL scale (resolve() @@ -1413,16 +1321,6 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any else np.ones(len(data), dtype=np.float64) ) raw_unit_w_meta = raw_obs_w_meta[self._unit_first_panel_row] - # Per-row ES df provenance (M-092 completion): the post-overall - # survey-df snapshot is the df the ES rows' safe_inference used. - # None when no ES surface was built, under bootstrap (percentile - # inference used no df — the shipped producer convention), and for - # the replicate-undefined 0 sentinel (representable only via - # survey_metadata.df_survey). - _es_df_final: Optional[float] = None - if event_study_effects is not None and bootstrap_results is None: - if _survey_df_post_overall is not None and _survey_df_post_overall > 0: - _es_df_final = float(_survey_df_post_overall) self.results_ = EfficientDiDResults( group_time_effects=group_time_effects, overall_att=overall_att, @@ -1441,9 +1339,12 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any n_bootstrap=self.n_bootstrap, bootstrap_weights=self.bootstrap_weights, seed=self.seed, - event_study_effects=event_study_effects, - event_study_df=_es_df_final, - group_effects=group_effects, + # No fit-time ES/group surface since 4.0 (post-fit aggregate() + # recomputes / replays them); the legacy fields are None on + # every fit, as is the ES df provenance. + event_study_effects=None, + event_study_df=None, + group_effects=None, efficient_weights=stored_weights if stored_weights else None, omega_condition_numbers=stored_cond if stored_cond else None, control_group=self.control_group, diff --git a/diff_diff/efficient_did_aggregation.py b/diff_diff/efficient_did_aggregation.py index 3ed450f50..617f3eb4d 100644 --- a/diff_diff/efficient_did_aggregation.py +++ b/diff_diff/efficient_did_aggregation.py @@ -16,7 +16,8 @@ - ``_EfficientAggregationMixin`` — the six estimator methods that compute the overall / event-study / group aggregations from the per-(g,t) EIF dict plus O(n_units) bookkeeping. ``EfficientDiD`` inherits it for the - fit-time path, and ``EfficientDiDResults._aggregate_compute`` runs the + fit-time OVERALL aggregation (since 4.0 the fit materializes no event-study + or group surface), and ``EfficientDiDResults._aggregate_compute`` runs the same methods post-fit on a throwaway ``_EDiDKitAggregator`` host built from the retained :class:`~diff_diff.aggregation.AggregationKit` — which is what keeps ``aggregate()`` off an ``_estimator_ref``. @@ -96,7 +97,7 @@ def _compute_se_from_eif( class _EfficientAggregationMixin: - """EIF-based aggregation methods shared by fit-time and post-fit paths. + """EIF-based aggregation methods shared by the fit (overall) and post-fit paths. Not intended for standalone use. A host class must expose exactly the five attributes declared below — this is the ``_EDiDKitAggregator`` diff --git a/diff_diff/efficient_did_results.py b/diff_diff/efficient_did_results.py index 022df1cf8..9191e931c 100644 --- a/diff_diff/efficient_did_results.py +++ b/diff_diff/efficient_did_results.py @@ -815,12 +815,12 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: elif level == "event_study": if self.event_study_effects is None: raise ValueError( - "Event study effects not computed at fit time. Use " - "results.aggregate('event_study') for the post-fit " - "event-study container (bootstrapped fits replay the " - "fit-time multiplier bootstrap - no refit needed); a " - "result unpickled from a pre-3.9 release carries no " - "aggregation kit and must be refit." + "Event study effects are not stored on 4.0 fits; use " + "results.aggregate('event_study').to_dataframe() " + "(bootstrapped fits replay the fit-time multiplier " + "bootstrap - no refit needed); a result unpickled from " + "a pre-3.9 release carries no aggregation kit and must " + "be refit." ) rows = [] for rel_t, data in sorted(self.event_study_effects.items()): @@ -840,12 +840,12 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: elif level == "group": if self.group_effects is None: raise ValueError( - "Group effects not computed at fit time. Use " - "results.aggregate('group') for the post-fit group " - "container (bootstrapped fits replay the fit-time " - "multiplier bootstrap - no refit needed); a result " - "unpickled from a pre-3.9 release carries no " - "aggregation kit and must be refit." + "Group effects are not stored on 4.0 fits; use " + "results.aggregate('group').to_dataframe() " + "(bootstrapped fits replay the fit-time multiplier " + "bootstrap - no refit needed); a result unpickled from " + "a pre-3.9 release carries no aggregation kit and must " + "be refit." ) rows = [] for group, data in sorted(self.group_effects.items()): diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 1be5a55fe..d27d3bdbb 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -757,7 +757,7 @@ intensity of exposure differs): comparison under Assumption 6. `fit()` selects the mode from the panel shape: a two-period panel returns a single scalar WAS (Phase 2a); a multi-period panel returns per-event-time WAS estimates (Phase - 2b). The `aggregate=` override is deprecated (M-027). `did_had_pretest_workflow()` + 2b). The `aggregate=` override was removed at 4.0 (M-027). `did_had_pretest_workflow()` runs the paper's three-step TWFE-suitability battery: (1) QUG null via `qug_test`, (2) Assumption 7 pre-trends via `stute_test` / `stute_joint_pretest` (event-study path only; the two-period overall diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index b6e376047..94fb6fb8a 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -231,8 +231,6 @@ cs.fit( time: str, first_treat: str, # Column: first treatment period (0 or inf for never-treated) covariates: list[str] = None, - aggregate: str = None, # None, "simple", "event_study", "group", or "all" - balance_e: int = None, # Balance event study at this relative period ) -> CallawaySantAnnaResults ``` @@ -254,29 +252,30 @@ event_study = results.aggregate('event_study') # -> EventStudyResults by_cohort = results.aggregate('group') # -> AggregationResult ``` -Fit-time `aggregate=` / `balance_e=` are DEPRECATED since 3.9 (removed in 4.0, -ledger rows M-020 / M-117) and emit a `FutureWarning`. The downstream consumers -all accept the post-fit container directly - `plot_event_study`, -`compute_honest_did` and `compute_pretrends_power` each take -`results.aggregate('event_study')` - bootstrapped fits included: the -RECOMPUTE levels ('event_study'/'group') REPLAY the fit-time multiplier -bootstrap from the kit-retained RNG state (percentile se/CI/cband matching a -fit-time aggregation to floating-point reassociation; the container carries -vcov=None). Only pre-replay legacy pickles and artifacts moved across the -Rust/NumPy weight backend fail closed with a refit message - never a silent -substitution of analytical inference for percentile statistics. +`fit()` takes no `aggregate=` / `balance_e=` keyword (removed at 4.0, ledger +rows M-020 / M-117; passing them is a `TypeError`) and computes no event-study +or group surface: `results.event_study_effects` / `results.group_effects` are +`None` after every fit. Aggregation is post-fit, and the downstream consumers +all accept the container directly - `plot_event_study`, `compute_honest_did` +and `compute_pretrends_power` each take `results.aggregate('event_study')` - +bootstrapped fits included: the RECOMPUTE levels ('event_study'/'group') +REPLAY the fit-time multiplier bootstrap from the kit-retained RNG state +(percentile se/CI/cband matching a fit-time aggregation to floating-point +reassociation; the container carries the bootstrap-draw covariance as vcov). +Only pre-replay legacy pickles and artifacts moved across the Rust/NumPy +weight backend fail closed with a refit message - never a silent substitution +of analytical inference for percentile statistics. ```python -# Post-fit route (recommended): aggregate once, feed any consumer. +# Aggregate once post-fit, feed any consumer (analytical or bootstrapped fit). es = results.aggregate('event_study') plot_event_study(es) honest = compute_honest_did(es, M=1.0) -# Bootstrap fits only: aggregate at fit time. boot = CallawaySantAnna(estimation_method="dr", n_bootstrap=999, seed=42) -plotted = boot.fit(data, outcome='outcome', unit='unit', time='period', - first_treat='first_treat', aggregate='event_study') -plot_event_study(plotted) +boot_res = boot.fit(data, outcome='outcome', unit='unit', time='period', + first_treat='first_treat') +plot_event_study(boot_res.aggregate('event_study')) # replays the bootstrap ``` ### ChaisemartinDHaultfoeuille @@ -313,7 +312,6 @@ est.fit( # ---- multi-horizon ---- L_max: int | None = None, # Max horizon; None = l=1 only # ---- covariates and extensions ---- - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-026): warns; non-None raises - use results.aggregate() post-fit covariates: list[str] | None = None, # DID^X residualization-style covariates trends_linear: bool | None = None, # DID^{fd} group-specific linear trends trends_nonparam: Any | None = None, # DID^s state-set-specific trends @@ -323,7 +321,7 @@ est.fit( ) -> ChaisemartinDHaultfoeuilleResults ``` -`L_max` controls multi-horizon computation. `covariates`, `trends_linear`, `trends_nonparam`, `honest_did`, `heterogeneity`, `design2`, and `survey_design` are all supported. Fit-time `aggregate=` is DEPRECATED (row M-026: it never computed anything here; supplying it warns and any non-None value raises) - aggregation is post-fit: `results.aggregate('event_study')` returns the unified `EventStudyResults` view (Phase-1 fits the 2-row l=1 view, `L_max>=1` the multi-horizon surface) and `results.aggregate('simple')` a one-row `AggregationResult` relaying the overall estimand (DID_M / DID_1 / delta) bit-exactly; both are pure views, so bootstrap fits are permitted. The dCDH container is deliberately NOT accepted by `compute_honest_did`/`compute_pretrends_power` (their l1 placebo semantics need the native dCDH branch - pass the results object itself). The 3.8 names `group=` and `controls=` remain accepted as deprecated aliases (FutureWarning; removed in 4.0). +`L_max` controls multi-horizon computation. `covariates`, `trends_linear`, `trends_nonparam`, `honest_did`, `heterogeneity`, `design2`, and `survey_design` are all supported. `fit()` takes no `aggregate=` keyword (removed at 4.0, row M-026: it never computed anything here) - aggregation is post-fit: `results.aggregate('event_study')` returns the unified `EventStudyResults` view (Phase-1 fits the 2-row l=1 view, `L_max>=1` the multi-horizon surface) and `results.aggregate('simple')` a one-row `AggregationResult` relaying the overall estimand (DID_M / DID_1 / delta) bit-exactly; both are pure views, so bootstrap fits are permitted. The dCDH container is deliberately NOT accepted by `compute_honest_did`/`compute_pretrends_power` (their l1 placebo semantics need the native dCDH branch - pass the results object itself). The 3.8 names `group=` and `controls=` were removed in 4.0 - use `unit=` and `covariates=`. **Usage:** @@ -844,7 +842,6 @@ had.fit( time: str, unit: str, first_treat: str | None = None, # Required on staggered panels (last-cohort auto-filter trigger) - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-027) - removed in 4.0; fit() selects the mode from the panel shape (2 distinct periods -> overall scalar WAS; more -> per-horizon event-study WAS) and post-fit results.aggregate() is the aggregation surface; supplying a value warns then runs the legacy routing *, cband: bool = True, # Simultaneous (sup-t) confidence bands on event-study fits that are survey-weighted OR clustered (keyword-only) survey_design: SurveyDesign | None = None, # Survey-design kwarg (weights, strata, PSU, FPC) — the sole weighting entry @@ -1118,7 +1115,6 @@ stacked.fit( unit: str, time: str, first_treat: str, - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-024): warns; surface always computed - use results.aggregate() post-fit population: str = None, # Required when weighting="population" covariates: list[str] = None, # Columns to balance (requires balance="entropy"); values read at t=a-1-anticipation; balanced windows only ) -> StackedDiDResults @@ -1168,8 +1164,6 @@ edid.fit( time: str, first_treat: str, covariates: list[str] = None, # Time-invariant unit-level covariates; uses doubly-robust sieve path when non-None - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-023): warns, still works - use results.aggregate() post-fit - balance_e: Any = NOT_SUPPLIED, # DEPRECATED (M-120): moves onto results.aggregate(balance_e=) ) -> EfficientDiDResults ``` @@ -1900,10 +1894,10 @@ Returned by `CallawaySantAnna.fit()`. | `groups` | `list` | Treatment cohorts | | `time_periods` | `list` | All time periods | | `n_obs` | `int` | Number of observations | -| `event_study_effects` | `dict[int, dict]` | Event study effects by relative time. Populated only by the DEPRECATED fit-time `aggregate=`; `None` after a plain fit - use `aggregate("event_study")` instead | -| `group_effects` | `dict` | Group-level aggregated effects. Same: populated only by fit-time `aggregate=` | +| `event_study_effects` | `dict[int, dict] | None` | Legacy field - `None` on EVERY fit since 4.0 (fit() computes no event-study surface); use `aggregate("event_study")` | +| `group_effects` | `dict | None` | Legacy field - `None` on EVERY fit since 4.0; use `aggregate("group")` | -**Methods:** `aggregate(type, weights=None, *, balance_e=None)`, `summary()`, `print_summary()`, `to_dataframe(level="event_study"|"group_time"|"group")` +**Methods:** `aggregate(type, weights=None, *, balance_e=None)`, `summary()`, `print_summary()`, `to_dataframe(level="group_time")` (the event-study / group tables come from `aggregate(...).to_dataframe()`; `level="event_study"|"group"` raises on 4.0 fits) `aggregate("simple"|"event_study"|"group"|"total")` re-aggregates POST-FIT with no @@ -2115,9 +2109,9 @@ ImputationDiDResults, TwoStageDiDResults, StackedDiDResults, and EfficientDiDRes | `overall_t_stat` | `float` | T-statistic | | `overall_p_value` | `float` | P-value | | `overall_conf_int` | `tuple[float, float]` | CI | -| `event_study_effects` | `dict[int, dict]` | Event study effects (if the DEPRECATED fit-time aggregate included event_study; ALWAYS populated for StackedDiD since 3.9 - row M-024; post-fit `aggregate('event_study')` returns a CONTAINER, it does not populate this field) | +| `event_study_effects` | `dict[int, dict] | None` | Legacy field. `None` on every EfficientDiD fit since 4.0 (PR 3b) and on plain ImputationDiD/TwoStageDiD fits that omit `aggregate=`; ImputationDiD/TwoStageDiD still populate it via the deprecated fit-time `aggregate=` (removal pending, PR 3c); StackedDiD always populates it (row M-024). The canonical surface is the `aggregate('event_study')` CONTAINER | | `event_study_df` | `float | dict | None` | Producer-side per-row df channel for the unified event-study container. On `EfficientDiDResults`/`ImputationDiDResults` (3.12, M-092 completion): the scalar survey df governing the ES rows' t-inference (ImputationDiD threads the FINAL replicate-override value; leads included on survey fits); `None` on non-survey fits, on bootstrapped fits (percentile inference used no df), when no fit-time ES surface was built, and for the replicate-undefined `0` sentinel | -| `group_effects` | `dict` | Group-level effects (if the deprecated fit-time aggregate included group; post-fit `aggregate('group')` returns an AggregationResult container) | +| `group_effects` | `dict | None` | Legacy field. `None` on every EfficientDiD fit since 4.0 (PR 3b) and on plain ImputationDiD/TwoStageDiD fits that omit `aggregate=`; ImputationDiD/TwoStageDiD still populate it via the deprecated fit-time `aggregate=` (removal pending, PR 3c). The canonical surface is the `aggregate('group')` AggregationResult container | | `groups` | `list` | Treatment cohorts | | `time_periods` | `list` | All time periods | | `n_obs` | `int` | Number of observations | diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index 769f81767..acacae4ec 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -63,13 +63,13 @@ results.att # ATT results.se # Standard error results.conf_int # Confidence interval -# CallawaySantAnna (with aggregate parameter): +# CallawaySantAnna (post-fit aggregate(); fit() takes no aggregate= keyword): results.overall_att # Overall ATT results.overall_se # Standard error -results.group_effects # Per-cohort ATTs (deprecated fit-time route only; - # post-fit: results.aggregate('group')) -results.event_study_effects # Dynamic effects (deprecated fit-time route only; - # post-fit: results.aggregate('event_study')) +results.aggregate('group') # Per-cohort ATTs (results.group_effects is + # None on every 4.0 fit) +results.aggregate('event_study') # Dynamic effects (results.event_study_effects + # is None on every 4.0 fit) # SunAbraham (no aggregate parameter — effects computed automatically): results.overall_att # Overall ATT @@ -405,8 +405,8 @@ Works with MultiPeriodDiD (deprecated - the TWFE event-study surface also admits estimated pre-periods exist). - For CS: pass the post-fit container - `compute_honest_did( - results.aggregate('event_study'))` - no refit needed (the fit-time - `aggregate=` route is deprecated since 3.9). BOOTSTRAPPED fits + results.aggregate('event_study'))` - no refit needed (`fit()` takes no + `aggregate=` keyword since 4.0). BOOTSTRAPPED fits (`n_bootstrap > 0`) included: the recompute levels REPLAY the fit-time multiplier bootstrap from the kit-retained RNG state (percentile inference; the container's vcov is the covariance of the @@ -483,8 +483,9 @@ Aggregate treatment effects may mask important variation. ### For CallawaySantAnna, EfficientDiD, ImputationDiD, TwoStageDiD (post-fit `aggregate()`) ```python -# Post-fit, no refit needed (fit-time aggregate= is deprecated since 3.9 -# for CS/StackedDiD/EfficientDiD/ImputationDiD/TwoStageDiD; removed in 4.0): +# Post-fit, no refit needed (the fit-time aggregate= / balance_e= keywords +# were removed at 4.0 on CS/StackedDiD/EfficientDiD and remain deprecated on +# ImputationDiD/TwoStageDiD until their removal): results = cs.fit(data, ...) print(results.overall_att) # Overall ATT print(results.aggregate('group').to_dataframe()) # Per-cohort ATTs diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index 46567e0f5..813a3aa8c 100644 --- a/diff_diff/guides/llms.txt +++ b/diff_diff/guides/llms.txt @@ -21,7 +21,7 @@ diagnostic steps produces unreliable results. 4. **Choose estimator** — staggered adoption → CS/SA/BJS (NOT plain TWFE); few treated units → SDiD; factor confounding → TROP; simple 2x2 → DiD. Run `BaconDecomposition` to diagnose TWFE bias. 5. **Estimate** — `estimator.fit(data, ...)`. Always print the cluster count first and choose inference method based on the result (cluster-robust if >= 50 clusters, wild bootstrap if fewer — for DifferenceInDifferences pass `cluster=`; TwoWayFixedEffects auto-clusters at unit level). 6. **Sensitivity analysis** — `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; the TwoWayFixedEffects `event_study=True` surface and a StackedDiD `results.aggregate('event_study')` container also admit - Stacked needs `kappa_pre >= 2` so estimated pre-periods exist), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs. -7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (fit-time `aggregate=`/`balance_e=` are deprecated since 3.9, removed in 4.0; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; BOOTSTRAPPED CS fits included: the recompute levels `'event_study'`/`'group'` REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; the container's vcov is the covariance of the bootstrap draws, so its diagonal is the bootstrap se**2), while `.aggregate('simple')` and, where supported, `.aggregate('total')` relay the stored bootstrap inference (NaN df column); only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message). NEW in 3.10: `.aggregate('total')` on CS/EfficientDiD/ImputationDiD/TwoStageDiD - the estimator-owned total incremental outcome (exact relay C x overall over the finite-masked complete-case support; single target='total' row; bootstrap-safe RELAY; panel non-survey fits only - repeated-cross-section and declared survey_design fits raise NotImplementedError with the reason); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; StackedDiD: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (the surface is ALWAYS computed at fit since 3.9 - row M-024 - and the container admits into `compute_honest_did`/`compute_pretrends_power` with `kappa_pre >= 2`); EDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit, RECOMPUTED from retained EIFs (3.9, row M-023; fit-time `aggregate=`/`balance_e=` deprecated; on bootstrapped EDiD fits the recompute levels REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message) while `.aggregate('simple')` relays the stored bootstrap inference; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/TwoStageDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit on ImputationDiD and TwoStageDiD too (3.9, rows M-021/M-022; recomputed from panel-backed kits, `balance_e=` on `aggregate('event_study')`; on bootstrapped fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the deprecated fit-time aggregation for a bootstrapped ES/group surface; their containers are not admitted into honest/pretrends - Imputation by design, TwoStage deferred pending a normalization derivation); CGBS continuous: ContinuousDiD is a MIXED adopter (3.9, row M-025) - `results.aggregate('dose')` (ATT(d)+ACRT(d) rows) and `.aggregate('simple')` (att+acrt rows) are views over the always-computed curves and work on ANY fit incl. bootstrapped, while `.aggregate('event_study')` recomputes the binarized event study from a pruned per-cell IF kit and raises on bootstrapped fits (use the deprecated fit-time `aggregate='eventstudy'` there until 4.0; its container is not admitted into honest/pretrends - no joint ES covariance and no reference normalization); HAD: `results.aggregate('simple')` (overall two-period fits; the target column carries the WAS estimand label) / `.aggregate('event_study')` (multi-period fits) - pure views, work on any fit (3.9, rows M-027/M-139; fit() selects the mode from the panel shape; HAD containers are not admitted into honest/pretrends - no joint cross-horizon covariance, deferred); subgroup re-estimation. +7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (the fit-time `aggregate=`/`balance_e=` keywords were removed at 4.0 and `results.event_study_effects`/`group_effects` are None on every CS fit; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; BOOTSTRAPPED CS fits included: the recompute levels `'event_study'`/`'group'` REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; the container's vcov is the covariance of the bootstrap draws, so its diagonal is the bootstrap se**2), while `.aggregate('simple')` and, where supported, `.aggregate('total')` relay the stored bootstrap inference (NaN df column); only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message). NEW in 3.10: `.aggregate('total')` on CS/EfficientDiD/ImputationDiD/TwoStageDiD - the estimator-owned total incremental outcome (exact relay C x overall over the finite-masked complete-case support; single target='total' row; bootstrap-safe RELAY; panel non-survey fits only - repeated-cross-section and declared survey_design fits raise NotImplementedError with the reason); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; StackedDiD: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (the surface is ALWAYS computed at fit since 3.9 - row M-024 - and the container admits into `compute_honest_did`/`compute_pretrends_power` with `kappa_pre >= 2`); EDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit, RECOMPUTED from retained EIFs (3.9, row M-023; the fit-time keywords were removed at 4.0; on bootstrapped EDiD fits the recompute levels REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message) while `.aggregate('simple')` relays the stored bootstrap inference; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/TwoStageDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit on ImputationDiD and TwoStageDiD too (3.9, rows M-021/M-022; recomputed from panel-backed kits, `balance_e=` on `aggregate('event_study')`; on bootstrapped fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the deprecated fit-time aggregation for a bootstrapped ES/group surface; their containers are not admitted into honest/pretrends - Imputation by design, TwoStage deferred pending a normalization derivation); CGBS continuous: ContinuousDiD is a MIXED adopter (3.9, row M-025) - `results.aggregate('dose')` (ATT(d)+ACRT(d) rows) and `.aggregate('simple')` (att+acrt rows) are views over the always-computed curves and work on ANY fit incl. bootstrapped, while `.aggregate('event_study')` recomputes the binarized event study from a pruned per-cell IF kit and raises on bootstrapped fits (use the deprecated fit-time `aggregate='eventstudy'` there until 4.0; its container is not admitted into honest/pretrends - no joint ES covariance and no reference normalization); HAD: `results.aggregate('simple')` (overall two-period fits; the target column carries the WAS estimand label) / `.aggregate('event_study')` (multi-period fits) - pure views, work on any fit (3.9, rows M-027/M-139; fit() selects the mode from the panel shape; HAD containers are not admitted into honest/pretrends - no joint cross-horizon covariance, deferred); subgroup re-estimation. 8. **Robustness** — compare 2-3 estimators (CS vs SA vs BJS), MUST report with and without covariates (shows whether conditioning drives identification), present pre-trends and sensitivity bounds. After estimation, call `practitioner_next_steps(results)` for context-aware diff --git a/diff_diff/had.py b/diff_diff/had.py index 8e21c467c..8e513fdb4 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -73,7 +73,6 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.aggregation import AggregationMixin, AggregationResult, resolve_inference_df from diff_diff.bootstrap_chunking import ( compute_block_size, @@ -121,19 +120,19 @@ "continuous_near_d_lower", "mass_point", ) -_VALID_AGGREGATES = ("overall", "event_study") def _infer_aggregate_mode(data: pd.DataFrame, time_col: str) -> str: - """Select the estimation mode from the panel shape (rows M-027/M-139). + """Select the estimation mode from the panel shape. - ``fit(aggregate=)`` and ``did_had_pretest_workflow(aggregate=)`` are - deprecated; when the caller does not supply a mode, both surfaces - resolve it HERE from the same rule so they cannot drift: two distinct - time values -> the two-period ``"overall"`` estimator, more than two - -> the multi-period ``"event_study"`` estimator. Fewer than two - distinct values routes to the overall branch, whose panel validator - raises the existing two-period message. + THE mode selector for both :meth:`HeterogeneousAdoptionDiD.fit` and + :func:`did_had_pretest_workflow` (neither takes a mode keyword since + 4.0); both surfaces resolve the mode HERE from the same rule so they + cannot drift: two distinct time values -> the two-period + ``"overall"`` estimator, more than two -> the multi-period + ``"event_study"`` estimator. Fewer than two distinct values routes to + the overall branch, whose panel validator raises the existing + two-period message. The column check mirrors ``_validate_had_panel`` so a missing time column stays a ``ValueError`` naming the column (never a raw @@ -2790,10 +2789,10 @@ class HeterogeneousAdoptionDiD(BaseEstimator): the last-treatment cohort plus never-treated units (paper Appendix B.2 prescription). - ``fit()`` selects the mode from the panel shape; the deprecated - ``fit(aggregate=)`` override warns and dies in 4.0 (row M-027). - Post-fit, ``results.aggregate('simple')`` (overall fits) and - ``results.aggregate('event_study')`` (event-study fits) expose the + ``fit()`` selects the mode from the panel shape (two distinct periods + -> overall; more -> event study); there is no mode keyword. Post-fit, + ``results.aggregate('simple')`` (two-period fits) and + ``results.aggregate('event_study')`` (multi-period fits) expose the unified aggregation vocabulary. Parameters @@ -2861,7 +2860,7 @@ class HeterogeneousAdoptionDiD(BaseEstimator): clustering route through ``survey_design=SurveyDesign(weights='', psu='')`` instead). Cluster must be constant within unit. On the event-study path - (``aggregate="event_study"``, Phase 2b) ``cluster=`` provides + (multi-period panel, Phase 2b) ``cluster=`` provides cluster-robust per-horizon pointwise CIs (both designs) AND a cluster-robust simultaneous sup-t band (``cband=True``, fires even on unweighted fits); ``cluster=`` + ``survey_design=`` is rejected @@ -2910,7 +2909,7 @@ class HeterogeneousAdoptionDiD(BaseEstimator): non-testability caveat is surfaced by the Design 1 fit-time ``UserWarning`` and by T21 (HAD pretest workflow tutorial) prose, NOT by the composite workflow verdict string (which only flags the - Assumption 7 step-2 gap on the two-period ``aggregate="overall"`` + Assumption 7 step-2 gap on the two-period overall path). **Diagnostics coverage.** ``HeterogeneousAdoptionDiDResults.bandwidth_diagnostics`` @@ -2972,7 +2971,7 @@ def __init__( # Event-study sup-t simultaneous-CI support. ``n_bootstrap`` = # number of multiplier-bootstrap replicates for the sup-t band; # ``seed`` = reproducibility seed for the multiplier draws. Both are - # consulted when ``aggregate="event_study"`` + ``cband=True`` + # consulted on a multi-period (event-study) fit with ``cband=True`` # (default) AND either ``survey_design=`` (weighted/survey band, # Phase 4.5 B) OR ``cluster=`` (cluster-robust band, Phase 2b # — fires even unweighted). An unweighted, unclustered event-study @@ -3047,7 +3046,6 @@ def fit( time: str, unit: str, first_treat: Optional[str] = None, - aggregate: Any = NOT_SUPPLIED, *, cband: bool = True, survey_design: Any = None, @@ -3063,8 +3061,8 @@ def fit( "event_study" mode and returns a :class:`HeterogeneousAdoptionDiDEventStudyResults` with per- event-time WAS estimates using a uniform ``F-1`` anchor (paper - Appendix B.2). The deprecated ``aggregate=`` override (row - M-027) warns and is removed in 4.0. + Appendix B.2). There is no mode keyword: the panel shape is the + only selector. Both the overall and event-study paths are **panel-only**: the paper (Section 2) defines HAD on panel or repeated-cross-section data, @@ -3102,30 +3100,13 @@ def fit( (``did_multiplegt_dyn``) for full staggered support. See REGISTRY § "Library extension: Staggered-timing fail- closed" for the rationale on raising vs. warning. - aggregate : {"overall", "event_study"}, optional - DEPRECATED mode override (row M-027; ``FutureWarning``, - removed in 4.0). When omitted, ``fit()`` selects the mode - from the panel shape: two distinct periods -> "overall" - (Phase 2a, single-period WAS, - :class:`HeterogeneousAdoptionDiDResults`); more -> - "event_study" (Phase 2b, paper Appendix B.2, per-event-time - WAS, :class:`HeterogeneousAdoptionDiDEventStudyResults`; - pointwise CIs per horizon - joint cross-horizon covariance - is deferred). Supplying ANY value warns and then runs the - legacy routing unchanged (invalid values still raise - ``ValueError``; a supplied mode that mismatches the panel - shape still raises the shape error). Staggered-timing - panels: see the ``first_treat`` contract above (auto-filter - to last cohort + never-treated with ``UserWarning`` when - supplied; fail-closed ``ValueError`` when omitted on a - staggered panel). survey_design : SurveyDesign or None, keyword-only Survey design (sampling weights + optional strata / PSU / FPC) - for design-based inference. Supported on ALL design × aggregate + for design-based inference. Supported on ALL design × mode combinations after Phase 4.5 B: continuous paths (``continuous_at_zero``, ``continuous_near_d_lower``) on both - ``aggregate="overall"`` and ``aggregate="event_study"``, AND - the ``mass_point`` design on both aggregates. Continuous paths + the two-period (overall) and the multi-period (event-study) + panel, AND the ``mass_point`` design on both. Continuous paths compose the SE via :func:`compute_survey_if_variance` (Binder 1983 TSL); weights propagate pointwise into the lprobust kernel. Mass-point composes the per-unit 2SLS IF on the @@ -3144,15 +3125,15 @@ def fit( dispatch matrix. cband : bool, default True Controls the multiplier-bootstrap simultaneous confidence band - on the event-study path. When ``True`` (default) and - ``aggregate="event_study"`` AND either (a) ``survey_design=`` + on the event-study path. When ``True`` (default) on a + multi-period panel AND either (a) ``survey_design=`` is supplied (weighted/survey band, Phase 4.5 B) OR (b) ``cluster=`` is supplied (cluster-robust band — fires even on an unweighted fit, Phase 2b), the fit populates ``cband_low`` / ``cband_high`` / ``cband_crit_value`` / ``cband_method`` / ``cband_n_bootstrap`` on the result. When - ``False`` those fields stay ``None``. No effect on - ``aggregate="overall"`` or on unweighted, unclustered event- + ``False`` those fields stay ``None``. No effect on a + two-period (overall) fit or on unweighted, unclustered event- study. ``n_bootstrap`` and ``seed`` (constructor params) control replicate count and RNG; defaults are 999 / ``None``. trends_lin : bool, default False, keyword-only @@ -3162,10 +3143,10 @@ def fit( is estimated as ``Y[g, F-1] - Y[g, F-2]``; each event-time ``e`` evolution is replaced by ``dy_dict[e] - (e+1) × slope`` (uniform formula that absorbs both effect-side - detrending and placebo-side anchor swap). Requires - ``aggregate="event_study"`` AND ``F >= 3`` (panel must - include both ``F-1`` and ``F-2``); raises - ``NotImplementedError`` on ``aggregate="overall"`` and + detrending and placebo-side anchor swap). Requires a + multi-period panel (event-study mode) AND ``F >= 3`` (panel + must include both ``F-1`` and ``F-2``); raises + ``NotImplementedError`` on a two-period panel and ``ValueError`` on ``F < 3``. The "consumed" placebo at event time ``e=-2`` is auto-dropped (R reduces max placebo lag by 1 with the same effect). Mutually @@ -3194,31 +3175,13 @@ def fit( Notes ----- - On the ``aggregate="overall"`` path, ``fit()`` emits a ``UserWarning`` + On the two-period (overall) path, ``fit()`` emits a ``UserWarning`` when a non-trivial fraction (``>= 10%``, a library convention) of units have exactly-zero post-period dose — a genuine untreated mass for which a standard DiD may be more appropriate (de Chaisemartin et al. 2026, Section 2). The event-study path does not warn: it *requires* never-treated units per Appendix B.2. """ - # M-027 deprecation shim: a plain fit() never warns; supplying - # aggregate= with ANY value (None included) warns once, then the - # legacy routing below runs unchanged - invalid values still - # reach the pre-existing _VALID_AGGREGATES ValueError. The - # SENTINEL resolves to a panel-inferred mode at the - # _VALID_AGGREGATES check site below. - if aggregate is not NOT_SUPPLIED: - warnings.warn( - "HeterogeneousAdoptionDiD.fit(aggregate=) is deprecated " - "and will be removed in 4.0. fit() now selects the mode " - "from the panel shape (two distinct periods -> the " - "overall WAS estimator; more -> the event-study " - "estimator), and aggregation is a post-fit step: " - "results.aggregate('simple') on a two-period fit / " - "results.aggregate('event_study') on a multi-period fit.", - FutureWarning, - stacklevel=2, - ) # Body-local ``*_col`` names feed the private helpers; the public # parameters are the bare names. outcome_col = outcome @@ -3226,15 +3189,10 @@ def fit( time_col = time unit_col = unit first_treat_col = first_treat - # ---- aggregate / survey_design validation ---- - # Sentinel resolution (M-027): infer the mode from the panel shape - # when aggregate= was not supplied. - if aggregate is NOT_SUPPLIED: - aggregate = _infer_aggregate_mode(data, time_col) - elif aggregate not in _VALID_AGGREGATES: - raise ValueError( - f"Invalid aggregate={aggregate!r}. Must be one of " f"{_VALID_AGGREGATES}." - ) + # ---- mode selection ---- + # The panel shape is the only selector (two distinct periods -> + # "overall"; more -> "event_study"); there is no mode keyword. + mode = _infer_aggregate_mode(data, time_col) # ---- covariates= future-work trap (TODO L73) ---- # Covariate-adjusted HAD identification (de Chaisemartin et al. 2026, @@ -3243,7 +3201,7 @@ def fit( # (vs the bare TypeError a missing kwarg would raise) while leaving # unknown-kwarg typos a normal TypeError. Placed after the survey/weights # mutex and before the event-study dispatch so the single raise covers BOTH - # aggregate="overall" and aggregate="event_study". + # the two-period (overall) and the multi-period (event-study) mode. if covariates is not None: raise NotImplementedError( "HeterogeneousAdoptionDiD.fit(covariates=...) is not yet " @@ -3263,7 +3221,7 @@ def fit( # panel) and is currently event-study-only — the overall path is # 2-period and cannot accommodate the F-2 row. if trends_lin: - if aggregate != "event_study": + if mode != "event_study": raise NotImplementedError( "HAD.fit(trends_lin=True) requires a multi-period " "panel (the linear-trend slope estimator needs Y at " @@ -3319,9 +3277,9 @@ def fit( # The static return-type annotation on `fit()` is # Union[HeterogeneousAdoptionDiDResults, # HeterogeneousAdoptionDiDEventStudyResults]; callers should - # narrow via isinstance (or by the aggregate they passed) to - # access aggregate-specific fields. - if aggregate == "event_study": + # narrow via isinstance (or by the panel shape they passed) to + # access mode-specific fields. + if mode == "event_study": return self._fit_event_study( data=data, outcome_col=outcome_col, diff --git a/diff_diff/had_pretests.py b/diff_diff/had_pretests.py index a1c171170..d869479d6 100644 --- a/diff_diff/had_pretests.py +++ b/diff_diff/had_pretests.py @@ -35,7 +35,8 @@ Composite workflow: :func:`did_had_pretest_workflow` has two dispatch modes, selected from -the panel shape (the deprecated ``aggregate=`` override is row M-139): +the panel shape (there is no mode keyword; the selected mode is recorded +on the report's ``aggregate`` field): - The "overall" battery (two-period panel): runs steps 1 + 3 via :func:`qug_test` + :func:`stute_test` + :func:`yatchew_hr_test`. @@ -77,7 +78,6 @@ import pandas as pd from scipy import stats -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.bootstrap_utils import ( apply_stratum_centering, generate_survey_multiplier_weights_batch, @@ -707,7 +707,7 @@ class HADPretestReport(Diagnostic): def __repr__(self) -> str: # Preserve Phase 3 repr bit-exactly on the overall path. The - # aggregate kwarg is only surfaced on the event-study path so + # aggregate FIELD is only surfaced on the event-study path so # downstream consumers comparing repr strings on two-period # reports see identical output. if self.aggregate == "event_study": @@ -1451,8 +1451,8 @@ def qug_test( "boundary tests; no off-the-shelf survey-aware QUG exists.\n" "\n" "For survey-aware HAD pretesting, use the joint Stute family " - "via did_had_pretest_workflow(..., survey_design=..., " - "aggregate=...) -- shipped in Phase 4.5 C. The workflow " + "via did_had_pretest_workflow(..., survey_design=...) on a " + "multi-period panel -- shipped in Phase 4.5 C. The workflow " "skips the QUG step under survey_design= with a UserWarning " "and runs the linearity family with a PSU-level Mammen " "multiplier bootstrap (Stute) + weighted OLS + pweight-" @@ -3405,9 +3405,9 @@ def joint_pretrends_test( that the conditional mean depends on ``D``. Use this wrapper to close the paper's step-2 pre-trends gap that - :func:`did_had_pretest_workflow` otherwise flags. On a panel with - at least one earlier pre-period, the - ``aggregate="event_study"`` dispatch calls this wrapper internally. + :func:`did_had_pretest_workflow` otherwise flags. On a multi-period + panel with at least one earlier pre-period, the workflow's + event-study dispatch calls this wrapper internally. Parameters ---------- @@ -3797,8 +3797,8 @@ def joint_homogeneity_test( ``[1, D_g]``; the joint CvM tests whether the conditional mean is nonlinear in ``D`` in any horizon. - Used by :func:`did_had_pretest_workflow` with - ``aggregate="event_study"`` as the step-3 test (no joint Yatchew + Used by :func:`did_had_pretest_workflow` on a multi-period panel + (the event-study dispatch) as the step-3 test (no joint Yatchew variant exists - the paper does not derive one; users who need Yatchew-style adjacent-difference robustness can call :func:`yatchew_hr_test` on each (base, post) pair manually). @@ -4097,8 +4097,6 @@ def joint_homogeneity_test( ) -_VALID_AGGREGATES = ("overall", "event_study") - _QUG_DEFERRED_SUFFIX = ( " (linearity-conditional verdict; QUG-under-survey deferred per Phase 4.5 C0)" ) @@ -4223,14 +4221,14 @@ def did_had_pretest_workflow( n_bootstrap: int = 999, seed: Optional[int] = None, *, - aggregate: Any = NOT_SUPPLIED, survey_design: Any = None, trends_lin: bool = False, ) -> HADPretestReport: """Run the HAD pre-test workflow (paper Section 4.2-4.3). - Two dispatch modes, selected from the panel shape (row M-139; the - deprecated ``aggregate=`` override warns and dies in 4.0): + Two dispatch modes, selected from the panel shape - the same rule + :meth:`HeterogeneousAdoptionDiD.fit` uses; there is no mode keyword. + The selected mode is recorded on the report's ``aggregate`` field: The overall battery (two-period panel): runs paper steps 1 (:func:`qug_test`) and 3 (:func:`stute_test` + @@ -4262,22 +4260,22 @@ def did_had_pretest_workflow( ``base_period - 1`` is auto-dropped from step 2 and the workflow skips step 2 (``pretrends_joint=None``) if no earlier placebo survives. Mirrors R ``DIDHAD::did_had(..., trends_lin=TRUE)``. - Mutually exclusive with ``aggregate="overall"`` (raises + Mutually exclusive with the two-period (overall) battery (raises ``NotImplementedError``). Parameters ---------- data : pd.DataFrame - HAD panel. For ``aggregate="overall"``: balanced two-period - panel with pre-period dose = 0 for every unit. For - ``aggregate="event_study"``: balanced multi-period panel with + HAD panel. Two-period (overall battery): balanced two-period + panel with pre-period dose = 0 for every unit. Multi-period + (event-study battery): balanced multi-period panel with >= 3 periods, an ordered time dtype (numeric, datetime, or ordered categorical), and the pre-period D=0 invariant across all pre-periods. outcome, dose, time, unit : str first_treat : str or None, default None Optional first-treatment-period column. Required on the - ``aggregate="event_study"`` path when the panel is staggered + multi-period (event-study) path when the panel is staggered (multi-cohort); the panel validator auto-filters to the last cohort and emits ``UserWarning``. The overall path uses this for cross-validation only. @@ -4288,13 +4286,6 @@ def did_had_pretest_workflow( seed : int or None, default None Seed forwarded to the Stute bootstrap. QUG / Yatchew are deterministic. - aggregate : {"overall", "event_study"}, optional, keyword-only - DEPRECATED dispatch override (row M-139; ``FutureWarning``, - removed in 4.0). When omitted, the workflow selects the battery - from the panel shape - the same rule - :meth:`HeterogeneousAdoptionDiD.fit` uses. Supplying ANY value - warns and then runs the legacy routing unchanged; invalid - values still raise ``ValueError``. survey_design : SurveyDesign or None, keyword-only, default None Survey design for design-based pretest inference. Linearity-family pretests use PSU-level Mammen multiplier bootstrap (Stute family) @@ -4327,16 +4318,16 @@ def did_had_pretest_workflow( ``pretrends_joint`` / ``homogeneity_joint`` are ``None``. On the event-study path: ``pretrends_joint`` (``None`` if no earlier pre-period) and ``homogeneity_joint`` populated, ``stute`` / - ``yatchew`` are ``None``. ``aggregate`` is recorded on the - report for serialization dispatch. On the survey_design= path, - ``qug`` is ``None`` (Phase 4.5 C0 deferral); other components - populated as on the unweighted path. + ``yatchew`` are ``None``. The selected mode is recorded on the + report's ``aggregate`` field for serialization dispatch. On the + survey_design= path, ``qug`` is ``None`` (Phase 4.5 C0 deferral); + other components populated as on the unweighted path. Raises ------ ValueError - On invalid ``aggregate``; or any downstream front-door failure - (panel balance, dtype, dose invariant). + On any front-door failure (panel balance, dtype, dose + invariant). NotImplementedError If ``survey.replicate_weights is not None`` (replicate-weight pretests deferred to a parallel follow-up after Phase 4.5 C). @@ -4357,7 +4348,7 @@ def did_had_pretest_workflow( family (``d_lower > 0``). Assumptions 5/6 are non-testable via pre-trends. The composite verdict string does NOT mention Assumptions 5 or 6 — it only flags the Assumption 7 step-2 gap on - the two-period ``aggregate="overall"`` path. The Assumption 5/6 + the two-period (overall) path. The Assumption 5/6 caveat is surfaced separately by (a) the ``HeterogeneousAdoptionDiD.fit()`` fit-time ``UserWarning`` (which fires whenever the resolved design is Design 1 family — @@ -4380,17 +4371,17 @@ def did_had_pretest_workflow( survey deferred per Phase 4.5 C0"`` suffix to remind callers that admissibility is conditional on the linearity family alone. 4. **`all_pass`** drops the QUG-conclusiveness gate (one less - precondition). The linearity-conditional rule splits by aggregate: + precondition). The linearity-conditional rule splits by mode: - - ``aggregate="overall"`` survey: ``True`` iff at least one of + - two-period (overall) survey: ``True`` iff at least one of Stute/Yatchew is conclusive AND no conclusive test rejects (paper Section 4 step-3 "Stute OR Yatchew" wording). - - ``aggregate="event_study"`` survey: ``True`` iff + - multi-period (event-study) survey: ``True`` iff ``pretrends_joint`` is non-None and conclusive, ``homogeneity_joint`` is conclusive, AND neither rejects. Both joint variants must be conclusive on the event-study path (same step-2 + step-3 closure as the unweighted - aggregate, just without the QUG step). + battery, just without the QUG step). Sister pretests are unchanged on the workflow path; direct callers can also pass ``survey_design=`` to :func:`stute_test`, @@ -4405,22 +4396,6 @@ def did_had_pretest_workflow( de Chaisemartin et al. (2026), Section 4.2-4.3, Theorem 4, Appendix D, Theorem 7. """ - # M-139 deprecation shim (the fit-side twin is row M-027): a plain - # workflow call never warns; supplying aggregate= with ANY value - # warns once, then the legacy routing runs unchanged. The SENTINEL - # resolves to the same panel-inferred mode fit() uses at the - # _VALID_AGGREGATES check site below. - if aggregate is not NOT_SUPPLIED: - warnings.warn( - "did_had_pretest_workflow(aggregate=) is deprecated and " - "will be removed in 4.0. The workflow now selects its " - "dispatch from the panel shape (two distinct periods -> " - "the two-period pretest battery; more -> the multi-period " - "event-study battery), the same rule " - "HeterogeneousAdoptionDiD.fit() uses.", - FutureWarning, - stacklevel=2, - ) # Body-local ``*_col`` names feed the private helpers; the public # parameters are the bare names. outcome_col = outcome @@ -4428,14 +4403,10 @@ def did_had_pretest_workflow( time_col = time unit_col = unit first_treat_col = first_treat - # Sentinel resolution (M-139, mirroring fit's M-027 shim): infer the - # mode from the panel shape when aggregate= was not supplied. - if aggregate is NOT_SUPPLIED: - aggregate = _infer_aggregate_mode(data, time_col) - elif aggregate not in _VALID_AGGREGATES: - raise ValueError( - f"aggregate must be one of {list(_VALID_AGGREGATES)!r}; " f"got {aggregate!r}." - ) + # Mode selection: the panel shape is the only selector (two distinct + # periods -> the two-period battery; more -> the event-study battery), + # the same rule HeterogeneousAdoptionDiD.fit() uses. + mode = _infer_aggregate_mode(data, time_col) # ---- trends_lin scope gate (PR #392 R1 P1). # `trends_lin=True` is only meaningful on the event-study path because @@ -4443,7 +4414,7 @@ def did_had_pretest_workflow( # overall path runs qug + stute + yatchew on a 2-period panel and has # no joint-pretest surface to receive the kwarg. Front-door reject # rather than silently ignore. - if trends_lin and aggregate != "event_study": + if trends_lin and mode != "event_study": raise NotImplementedError( "did_had_pretest_workflow(trends_lin=True) requires a " "multi-period panel (the trends_lin kwarg forwards into " @@ -4454,11 +4425,11 @@ def did_had_pretest_workflow( ) # R6 P1 fix: do NOT call _resolve_pretest_unit_weights on the FULL panel - # here -- under aggregate='event_study' the panel may be staggered and the - # cohort filter at _validate_multi_period_panel can drop units. If those - # dropped units have zero/invalid weights, eager full-panel resolution + # here -- on a multi-period (event-study) panel the panel may be staggered + # and the cohort filter at _validate_multi_period_panel can drop units. If + # those dropped units have zero/invalid weights, eager full-panel resolution # would abort an otherwise-valid event-study run. Defer resolution to the - # per-aggregate branches: overall path resolves on the original data (no + # per-mode branches: overall path resolves on the original data (no # filtering); event-study path lets the joint wrappers handle resolution # on data_filtered. # Internal variable naming: downstream code reads `survey` (Phase 4.5 C @@ -4479,7 +4450,7 @@ def did_had_pretest_workflow( stacklevel=2, ) - if aggregate == "event_study": + if mode == "event_study": F, t_pre_list, t_post_list, data_filtered, _filter_info = _validate_multi_period_panel( data, outcome_col=outcome_col, diff --git a/diff_diff/honest_did.py b/diff_diff/honest_did.py index b74d9368c..0273fa1b5 100644 --- a/diff_diff/honest_did.py +++ b/diff_diff/honest_did.py @@ -1312,9 +1312,7 @@ def _extract_event_study_params( f"{type(results).__name__} must have event_study_effects " "for HonestDiD. Pass the post-fit container " "(compute_honest_did(results.aggregate('event_study')))" - " instead; CallawaySantAnna fits can alternatively use " - "the deprecated aggregate='event_study' to populate " - "the fit-time surface." + " instead." ) # Common-reference guard, mirroring the container branch: diff --git a/diff_diff/pretrends.py b/diff_diff/pretrends.py index 11060a94e..ad8e8a6eb 100644 --- a/diff_diff/pretrends.py +++ b/diff_diff/pretrends.py @@ -1182,10 +1182,7 @@ def _extract_pre_period_params( f"{type(results).__name__} must have " "event_study_effects. Pass the post-fit container " "(compute_pretrends_power(results.aggregate(" - "'event_study'), ...)) instead; CallawaySantAnna " - "fits can alternatively use the deprecated " - "aggregate='event_study' to populate the fit-time " - "surface." + "'event_study'), ...)) instead." ) # Common-reference guard, mirroring the container branch diff --git a/diff_diff/stacked_did.py b/diff_diff/stacked_did.py index a7072123e..f86ecb63e 100644 --- a/diff_diff/stacked_did.py +++ b/diff_diff/stacked_did.py @@ -25,7 +25,6 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.balancing import BalanceError, entropy_balance from diff_diff.linalg import effective_cluster_count, solve_ols from diff_diff.stacked_did_results import StackedDiDResults # noqa: F401 (re-export) @@ -301,7 +300,6 @@ def fit( unit: str, time: str, first_treat: str, - aggregate: Any = NOT_SUPPLIED, population: Optional[str] = None, survey_design=None, covariates: Optional[List[str]] = None, @@ -322,17 +320,6 @@ def fit( first_treat : str Name of column indicating when unit was first treated. Use 0 or np.inf for never-treated units. - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0; ledger row M-024): the - event-study surface is now always computed at fit, so this - parameter is behaviorally inert - passing it (any value, - ``None`` included) emits a ``FutureWarning``. Aggregate as a - post-fit step instead: ``results.aggregate('event_study')`` / - ``results.aggregate('simple')``. Value validation is - unchanged: ``"group"``/``"all"`` raise ``ValueError`` - (the pooled stacked regression cannot produce - cohort-specific effects - use CallawaySantAnna or - ImputationDiD), as do unknown values. population : str, optional Column name for population weights. Required only when weighting="population". @@ -352,56 +339,25 @@ def fit( Returns ------- StackedDiDResults - Object containing all estimation results. + Object containing all estimation results. The event-study surface + is always computed at fit; aggregate as a post-fit step via + ``results.aggregate('event_study')`` / ``results.aggregate('simple')`` + (``'group'`` is not available: the pooled stacked regression + cannot produce cohort-specific effects - use CallawaySantAnna or + ImputationDiD for cohort-level estimates). Raises ------ ValueError If required columns are missing or data validation fails. """ - # ---- fit(aggregate=) shim (row M-024) ---- - # Deprecated in 3.9, removed at 4.0: the event-study surface is now - # ALWAYS computed at fit (see the extraction block below), so the - # param is behaviorally inert and aggregation moves to the post-fit - # results.aggregate(type=...). The sentinel default means the - # warning fires ONLY when the caller supplies the argument (None - # included), never on a plain fit(). Value validation below is - # unchanged, so the deprecated path returns exactly what a plain - # fit does - or raises exactly as it always did. - if aggregate is not NOT_SUPPLIED: - warnings.warn( - "StackedDiD.fit(aggregate=) is deprecated and will be " - "removed in 4.0. The event-study surface is now always " - "computed; aggregate as a post-fit step: " - "results.aggregate('event_study') / " - "results.aggregate('simple').", - FutureWarning, - stacklevel=2, - ) - else: - aggregate = None - # Fit-time re-check: __init__ and set_params validate eagerly, so # this only catches DIRECT attribute mutation (est.anticipation = ...) # — an out-of-domain value silently changes the ESTIMAND. The - # assignment also re-normalizes a mutated numpy scalar to int. Placed - # AFTER the deprecation shim so a caller who both mutated and passed - # a deprecated argument still sees the FutureWarning before the raise. + # assignment also re-normalizes a mutated numpy scalar to int. self.anticipation = validate_anticipation(self.anticipation) # ---- Validate inputs ---- - if aggregate in ("group", "all"): - raise ValueError( - f"aggregate='{aggregate}' is not supported by StackedDiD. " - "The pooled stacked regression cannot produce cohort-specific " - "effects. Use CallawaySantAnna or ImputationDiD for " - "cohort-level estimates." - ) - if aggregate not in (None, "simple", "event_study"): - raise ValueError( - f"aggregate must be None, 'simple', or 'event_study', " f"got '{aggregate}'" - ) - required_cols = [outcome, unit, time, first_treat] if population is not None: required_cols.append(population) @@ -780,8 +736,8 @@ def fit( # 3.9 (row M-024): the per-event contrasts are ALWAYS built - # the event-study surface is always materialized so the # post-fit results.aggregate("event_study") view is total. - # (The pre-M-024 gate skipped them when aggregate != - # "event_study"; that gate's premise died with the post-fit + # (The pre-3.9 gate skipped them unless an event study was + # requested at fit; that gate's premise died with the post-fit # surface.) NB: the dof helper's degeneracy guard is # batch-relative for m > 1 (linalg.py), so the overall-ATT # contrast now shares a batch with the per-event contrasts; @@ -939,8 +895,8 @@ def _refit_stacked(w_r): # ---- Extract event study effects ---- # 3.9 (row M-024): the surface is ALWAYS materialized - the # regression always includes the event-time interactions, and the - # post-fit results.aggregate("event_study") view must be total. - # fit(aggregate=) no longer affects what is computed or stored. + # post-fit results.aggregate("event_study") view must be total: + # the event-study surface is always computed at fit. event_study_effects: Dict[int, Dict[str, Any]] = {} es_vcov: Optional[np.ndarray] = None es_vcov_index: Optional[List[int]] = None diff --git a/diff_diff/staggered.py b/diff_diff/staggered.py index f241bbfe8..b35b60530 100644 --- a/diff_diff/staggered.py +++ b/diff_diff/staggered.py @@ -17,10 +17,6 @@ AggregationKit, BootstrapReplaySpec, ) -from diff_diff.bootstrap_utils import ( - apply_bootstrap_event_study_overrides, - apply_bootstrap_group_overrides, -) from diff_diff.linalg import ( _check_propensity_diagnostics, _detect_rank_deficiency, @@ -36,7 +32,6 @@ from diff_diff.staggered_bootstrap import ( CallawaySantAnnaBootstrapMixin, CSBootstrapResults, - apply_cband_conf_ints, ) # Import from split modules @@ -67,22 +62,6 @@ PrecomputedData = Dict[str, Any] -class _DeprecatedFitArg: - """Sentinel for `fit(aggregate=)` / `fit(balance_e=)` (rows M-020 / M-117). - - A plain ``None`` default cannot distinguish "not passed" from "passed - None", so a bare ``None`` default would fire the FutureWarning on EVERY - fit. The warning must fire only when the caller actually supplies the - argument. - """ - - def __repr__(self) -> str: # pragma: no cover - debugging aid - return "" - - -_DEPRECATED_FIT_ARG = _DeprecatedFitArg() - - def _linear_regression( X: np.ndarray, y: np.ndarray, @@ -535,14 +514,11 @@ class CallawaySantAnna( >>> event_study.to_dataframe() # doctest: +SKIP Plotting and the sensitivity analyses (``plot_event_study``, - ``compute_honest_did``, ``compute_pretrends_power``) still read the - fit-time surface, so they take a fit that requested aggregation: + ``compute_honest_did``, ``compute_pretrends_power``) take the post-fit + container: >>> from diff_diff import plot_event_study - >>> plotted = cs.fit(data, outcome='outcome', unit='unit', - ... time='time', first_treat='first_treat', - ... aggregate='event_study') # doctest: +SKIP - >>> plot_event_study(plotted) # doctest: +SKIP + >>> plot_event_study(results.aggregate('event_study')) # doctest: +SKIP With covariate adjustment (conditional parallel trends): @@ -1870,8 +1846,6 @@ def fit( time: str, first_treat: str, covariates: Optional[List[str]] = None, - aggregate: Any = _DEPRECATED_FIT_ARG, - balance_e: Any = _DEPRECATED_FIT_ARG, survey_design: Optional["SurveyDesign"] = None, ) -> CallawaySantAnnaResults: """ @@ -1894,36 +1868,6 @@ def fit( Use 0 (or np.inf) for never-treated units. covariates : list, optional List of covariate column names for conditional parallel trends. - aggregate : str, optional - DEPRECATED since 3.9, removed in 4.0 (ledger row M-020). Passing - it emits a ``FutureWarning``. Aggregate as a post-fit step - instead - ``results.aggregate("simple" | "event_study" | - "group")`` - which needs no refit and returns a new object, - leaving ``results`` unchanged. Accepted values are unchanged: - - None: Only compute ATT(g,t) (default) - - "simple": Simple weighted average (overall ATT) - - "event_study": Aggregate by relative time (event study) - - "group": Aggregate by treatment cohort - - "all": Compute all aggregations - - ``compute_honest_did``, ``compute_pretrends_power`` and - ``plot_event_study`` all accept the post-fit container from - ``results.aggregate('event_study')`` directly, so no consumer - requires the fit-time surface anymore. Bootstrapped fits - (``n_bootstrap > 0``) included: the post-fit RECOMPUTE levels - (``'event_study'``/``'group'``) REPLAY the fit-time multiplier - bootstrap from the kit-retained RNG state (percentile - inference matching a fit-time aggregation to floating-point - reassociation; ``aggregate('simple')``/``('total')`` still - relay the stored inference bit-exactly). This parameter - remains only as the deprecated compatibility path through - 3.9. - balance_e : int, optional - DEPRECATED since 3.9, removed in 4.0 (ledger row M-117). Passing - it emits a ``FutureWarning``; it moves onto the post-fit call as - ``results.aggregate("event_study", balance_e=...)``. For event - study, balance the panel at relative time e. Ensures all groups - contribute to each relative period. survey_design : SurveyDesign, optional Survey design specification. Supports pweight with strata/PSU/FPC. Aggregated SEs (overall, event study, group) use design-based @@ -1935,41 +1879,15 @@ def fit( Returns ------- CallawaySantAnnaResults - Object containing all estimation results. + Object containing all estimation results. Aggregations are a + post-fit step: ``results.aggregate("simple" | "event_study" | + "group" | "total", balance_e=...)``. Raises ------ ValueError If required columns are missing or data validation fails. """ - # ---- fit(aggregate=) / fit(balance_e=) shims (rows M-020 / M-117) ---- - # Deprecated in 3.9, removed at 4.0: aggregation moves to the post-fit - # results.aggregate(type=...). The sentinel default means the warning - # fires ONLY when the caller supplies the argument, never on a plain - # fit(). Routing is otherwise untouched, so the deprecated path returns - # exactly the numbers it always did. - _deprecated_passed = [ - name - for name, value in (("aggregate", aggregate), ("balance_e", balance_e)) - if not isinstance(value, _DeprecatedFitArg) - ] - if _deprecated_passed: - _args = " / ".join(f"{n}=" for n in _deprecated_passed) - warnings.warn( - f"CallawaySantAnna.fit({_args}) is deprecated and will be removed " - "in 4.0. Fit once, then aggregate as a post-fit step: " - "results = CallawaySantAnna().fit(...); " - "results.aggregate('event_study') / .aggregate('group') / " - ".aggregate('simple') / .aggregate('total'). balance_e moves onto aggregate() alongside " - "it: results.aggregate('event_study', balance_e=2).", - FutureWarning, - stacklevel=2, - ) - if isinstance(aggregate, _DeprecatedFitArg): - aggregate = None - if isinstance(balance_e, _DeprecatedFitArg): - balance_e = None - # Validate pscore_trim (may have been changed by direct mutation). # Return discarded: fit() must not mutate constructor config. validate_pscore_trim(self.pscore_trim) @@ -2814,44 +2732,14 @@ def fit( df=df_survey, ) - # Compute additional aggregations if requested + # Since 4.0 fit() computes no event-study / group surface: both are + # post-fit steps (``results.aggregate('event_study' | 'group')``, + # which recomputes from the retained kit and, on bootstrapped fits, + # replays the multiplier bootstrap). The legacy fields are None on + # every fit. event_study_effects = None group_effects = None - # Aggregation outputs that used to arrive as ``self._event_study_*`` - # side channels; the aggregator is now pure and returns them. - es_aggregation = None - if aggregate in ["event_study", "all"]: - es_aggregation = self._aggregate_event_study( - group_time_effects, - influence_func_info, - treatment_groups, - time_periods, - balance_e, - df, - unit, - precomputed, - ) - event_study_effects = es_aggregation.effects - # The stored fit-time surface is THIS aggregation's (possibly - # balance_e-restricted): its common-reference provenance must - # describe the RETAINED cohorts, not the fit-wide set, or the - # native consumer route would disagree with the equivalent - # post-fit container (route parity). The fit-wide tuple - # computed above stands only when no event-study surface was - # produced. - reference_event_times = es_aggregation.reference_event_times - - if aggregate in ["group", "all"]: - group_effects = self._aggregate_by_group( - group_time_effects, - influence_func_info, - treatment_groups, - precomputed=precomputed, - df=df, - unit=unit, - ) - # Reject replicate-weight designs for bootstrap — replicate variance # is an analytical alternative, not compatible with bootstrap if ( @@ -2872,8 +2760,8 @@ def fit( bootstrap_results = self._run_multiplier_bootstrap( group_time_effects=group_time_effects, influence_func_info=influence_func_info, - aggregate=aggregate, - balance_e=balance_e, + aggregate=None, + balance_e=None, treatment_groups=treatment_groups, time_periods=time_periods, df=df, @@ -2906,23 +2794,11 @@ def fit( group_time_effects[gt]["p_value"] = bootstrap_results.group_time_p_values[gt] group_time_effects[gt]["t_stat"] = float(gt_t_stats[idx]) - # Update event study effects with bootstrap SEs (batched). - # Shared helper (verbatim extraction) so the post-fit aggregate() - # replay applies exactly the same overrides. - apply_bootstrap_event_study_overrides( - event_study_effects, bootstrap_results, self.alpha - ) - - # Update group effects with bootstrap SEs (batched) — same shared - # helper, including the df_used clearing rule. - apply_bootstrap_group_overrides(group_effects, bootstrap_results, self.alpha) - - # Compute simultaneous confidence band CIs if cband is available + # No fit-time event-study surface -> the bootstrap never builds + # ``event_study_info`` and no simultaneous band is computed at fit; + # the post-fit replay (``results.aggregate('event_study')``) carries + # its own ``cband_crit_value``. cband_crit_value = None - if bootstrap_results is not None: - cband_crit_value = bootstrap_results.cband_crit_value - - apply_cband_conf_ints(event_study_effects, cband_crit_value) # Consolidated _safe_inv rank-guard warning (sibling of PR #9 # finding #17). Rank-deficient PS Hessian / OR bread matrices in the @@ -2948,21 +2824,12 @@ def fit( ) # Store results - # Retrieve event-study VCV from aggregation mixin (Phase 7d). - # When bootstrap overwrites the event-study SEs, the VCV is the - # covariance of the same bootstrap draws (diagonal = bootstrap SE**2), - # so HonestDiD never mixes the analytical VCV with bootstrap SEs. - event_study_vcov = es_aggregation.vcov if es_aggregation is not None else None - event_study_vcov_index = es_aggregation.vcov_index if es_aggregation is not None else None - if bootstrap_results is not None: - event_study_vcov = bootstrap_results.event_study_vcov - event_study_vcov_index = bootstrap_results.event_study_vcov_index - # Same clearing rule for the ES df provenance: bootstrap replaces the - # stored ES se/p/CI with percentile values that never used the - # analytical df, so surfacing it would be false provenance. - event_study_df = es_aggregation.df_used if es_aggregation is not None else None - if bootstrap_results is not None: - event_study_df = None + # No fit-time event-study surface since 4.0, so there is no VCV / + # vcov index / df provenance to store; the post-fit container from + # ``results.aggregate('event_study')`` carries them. + event_study_vcov = None + event_study_vcov_index = None + event_study_df = None # Resolve canonical cluster_name + n_clusters for Results metadata. # Canonical PSU column wins when explicit: if survey_design has diff --git a/diff_diff/staggered_results.py b/diff_diff/staggered_results.py index 8fd09c8cb..05167cb7e 100644 --- a/diff_diff/staggered_results.py +++ b/diff_diff/staggered_results.py @@ -970,8 +970,8 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: elif level == "event_study": if self.event_study_effects is None: raise ValueError( - "Event study effects not computed. " - "Call results.aggregate('event_study') to compute them post-fit " + "Event study effects are not stored on 4.0 fits; use " + "results.aggregate('event_study').to_dataframe() " "(no refit required)." ) rows = [] @@ -995,8 +995,8 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: elif level == "group": if self.group_effects is None: raise ValueError( - "Group effects not computed. " - "Call results.aggregate('group') to compute them post-fit " + "Group effects are not stored on 4.0 fits; use " + "results.aggregate('group').to_dataframe() " "(no refit required)." ) rows = [] diff --git a/diff_diff/visualization/_event_study.py b/diff_diff/visualization/_event_study.py index 31fb5baef..d73a53a72 100644 --- a/diff_diff/visualization/_event_study.py +++ b/diff_diff/visualization/_event_study.py @@ -1066,7 +1066,8 @@ def _extract_plot_data( "Expected MultiPeriodDiDResults, CallawaySantAnnaResults, " "SunAbrahamResults, ImputationDiDResults, " "ChaisemartinDHaultfoeuilleResults, EventStudyResults, or DataFrame. " - "For DMLDiDResults (no fit-time event-study surface), plot the " + "For a result that stores no event-study surface (DMLDiD, " + "EfficientDiD, every CallawaySantAnna fit since 4.0), plot the " "post-fit container: plot_event_study(results.aggregate(" "'event_study'))." ) diff --git a/docs/api/had.rst b/docs/api/had.rst index f57459641..3a106fd1a 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -160,8 +160,8 @@ et al. (2026). The composite orchestrator does NOT pick the HAD design path (``continuous_at_zero`` / ``continuous_near_d_lower`` / ``mass_point``); that is auto-detected inside :meth:`HeterogeneousAdoptionDiD.fit` from the dose support. The workflow has -two modes selected from the panel shape (row M-139; the deprecated -``aggregate=`` override warns and dies in 4.0): a two-period +two modes selected from the panel shape (row M-139; the former +``aggregate=`` override was removed at 4.0): a two-period first-differenced sample runs the single-period tests; a multi-period panel (three or more periods) runs the joint multi-period tests. Both modes return a unified :class:`~diff_diff.HADPretestReport`. diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index faedec817..942210194 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -1283,7 +1283,7 @@ The multiplier bootstrap uses random weights w_i with E[w]=0 and Var(w)=1: - **Note:** Repeated cross-sections (`panel=False`, Phase 7b): supports surveys like BRFSS, ACS annual, and CPS monthly where units are not followed over time. Uses cross-sectional DRDID (Sant'Anna & Zhao 2020, Section 4): `reg` matches `DRDID::reg_did_rc` (Eq 2.2), `dr` matches `DRDID::drdid_rc` (locally efficient, Eq 3.3+3.4 with 4 OLS fits), `ipw` matches `DRDID::std_ipw_did_rc`. Per-observation influence functions instead of per-unit. All three estimation methods support covariates and survey weights. - **Note:** Panel and RCS influence functions use the library-wide `phi_i = psi_i / n` convention (SE = `sqrt(sum(phi^2))`, algebraically equivalent to R's `sd(psi)*sqrt(n-1)/n`). Leading IF terms are computed on psi scale and divided by n; PS nuisance corrections are computed on psi scale (`score @ solve(Hessian)`) with a single `/n` conversion to phi. - **Note:** Non-survey DR path also includes nuisance IF corrections (PS + OR), matching the survey path structure (Phase 7a). Previously used plug-in IF only. As of v3.7 the non-survey reg and ipw paths carry their corrections too (OR estimation-effect / PS score), so the nuisance-IF treatment is method-uniform. -- **Note (post-fit aggregate() - rows M-020/M-117):** `fit(aggregate=)` is deprecated (3.9; removed 4.0) in favor of post-fit `results.aggregate(type, balance_e=)` on the fit-retained aggregation kit ('simple' relays the stored overall inference; 'event_study'/'group' recompute from the kit — on BOOTSTRAPPED fits they REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` RNG-state snapshot: percentile se/CI/cband matching a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose`, never bit-identity; the discrete percentile p-value is a count statistic outside that claim), df cleared (no analytical provenance beside percentile inference), vcov/vcov_index from the same bootstrap draws (see the bootstrap event-study covariance Note above). **Note (df_survey two-channel contract):** the container-level `df_survey` SCALAR - the fit's resolved scalar inference df (`EventStudyResults.df_survey`) - DELIBERATELY persists on bootstrapped fit-time and replayed surfaces (CS, DMLDiD, EfficientDiD publish it identically): it is a live consumer channel (HonestDiD's container branches consume it as the `df=` for their critical values on the containers they admit), and only the PER-ROW `df` column is the per-row inference-provenance channel that percentile bootstrap clears. A bootstrapped ImputationDiD survey fit with `pretrends=True` clears the per-row channel even though the lead rows retained analytical survey-df inference (the bootstrap loop skips pre-period horizons) - the shipped producer convention, backend-stamped and failing closed on a Rust-vs-NumPy weight-backend mismatch or a pre-replay legacy pickle — the M-027 per-level policy, with the former blanket fail-closed retired). `DiagnosticReport` now derives the event-study container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends`, `pretrends_power`, and `sensitivity` checks run without the deprecated fit-time kwarg — CS containers are M-093-admitted into `compute_pretrends_power` / `HonestDiD.sensitivity_analysis` with pinned raw-route parity, and the raw field, when present (the requested-but-empty `{}` included), always takes precedence. `heterogeneity` is unaffected (it reads `group_time_effects` on plain fits). Bootstrapped fits now DERIVE successfully via the percentile-bootstrap replay: the derived container carries the covariance of the bootstrap draws as `vcov`, so `parallel_trends` runs the joint Wald test and `pretrends_power`/`sensitivity` use the full covariance (the Bonferroni and diagonal-covariance fallbacks remain where no bootstrap covariance is stored); the replay's re-emitted fit-time warnings are recorded and republished per section. The fail-closed skip remains for kit-less/legacy pickles, backend-mismatched replays, and the sibling estimators' bootstrap gates. The recompute levels' replay RE-EMITS the fit-time bootstrap warnings by design (the relay levels' no-re-warn convention is scoped to 'simple'/'total'). +- **Note (post-fit aggregate() - rows M-020/M-117):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3b of the cut: `fit()` computes no event-study/group surface, so the legacy `event_study_effects`/`group_effects` fields - and `cband_crit_value`/`event_study_vcov`/`event_study_vcov_index`/`event_study_df` - are None after EVERY fit, `to_dataframe(level='event_study'|'group')` raises on fresh fits until the M-093 schema rewire, and `survey_design` is positional slot 7) in favor of post-fit `results.aggregate(type, balance_e=)` on the fit-retained aggregation kit ('simple' relays the stored overall inference; 'event_study'/'group' recompute from the kit — on BOOTSTRAPPED fits they REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` RNG-state snapshot: percentile se/CI/cband matching a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose`, never bit-identity; the discrete percentile p-value is a count statistic outside that claim), df cleared (no analytical provenance beside percentile inference), vcov/vcov_index from the same bootstrap draws (see the bootstrap event-study covariance Note above). **Note (df_survey two-channel contract):** the container-level `df_survey` SCALAR - the fit's resolved scalar inference df (`EventStudyResults.df_survey`) - DELIBERATELY persists on bootstrapped fit-time and replayed surfaces (CS, DMLDiD, EfficientDiD publish it identically): it is a live consumer channel (HonestDiD's container branches consume it as the `df=` for their critical values on the containers they admit), and only the PER-ROW `df` column is the per-row inference-provenance channel that percentile bootstrap clears. A bootstrapped ImputationDiD survey fit with `pretrends=True` clears the per-row channel even though the lead rows retained analytical survey-df inference (the bootstrap loop skips pre-period horizons) - the shipped producer convention, backend-stamped and failing closed on a Rust-vs-NumPy weight-backend mismatch or a pre-replay legacy pickle — the M-027 per-level policy, with the former blanket fail-closed retired). `DiagnosticReport` now derives the event-study container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends`, `pretrends_power`, and `sensitivity` checks run on every CS fit — CS containers are M-093-admitted into `compute_pretrends_power` / `HonestDiD.sensitivity_analysis` with pinned raw-route parity, and the raw field, when present (the requested-but-empty `{}` included), always takes precedence. `heterogeneity` is unaffected (it reads `group_time_effects` on plain fits). Bootstrapped fits now DERIVE successfully via the percentile-bootstrap replay: the derived container carries the covariance of the bootstrap draws as `vcov`, so `parallel_trends` runs the joint Wald test and `pretrends_power`/`sensitivity` use the full covariance (the Bonferroni and diagonal-covariance fallbacks remain where no bootstrap covariance is stored); the replay's re-emitted fit-time warnings are recorded and republished per section. The fail-closed skip remains for kit-less/legacy pickles, backend-mismatched replays, and the sibling estimators' bootstrap gates. The recompute levels' replay RE-EMITS the fit-time bootstrap warnings by design (the relay levels' no-re-warn convention is scoped to 'simple'/'total'). - **Note (post-fit `aggregate('total')` - the estimator-owned total incremental outcome, 3.10):** (a) **Estimand:** `total = C x overall_att` with `se = C x overall_se`, CI scaled by `C`, `t`/`p`/`df` inherited unchanged - an exact relay CONDITIONAL ON THE REALIZED AGGREGATION MASS (never call it unqualified "exact"): `C` is the design-fixed complete-case treated-observation count on every admitted routing, the inherited SE prices the overall's full inference (WIF/estimated-share terms included) but treats `C` as fixed at its realized value, and the random-mass `att*dC` variance term is deliberately out of scope (the DEFERRED survey/RC remainder). Every SE branch is homogeneous of degree 1 in the scaling, so the relay is exact under analytical AND percentile-bootstrap inference. (b) **Fail-closed routing gates**, in order: (1) repeated-cross-section-routed fits (`panel=False`, or genuinely-unbalanced `allow_unbalanced_panel=True` fits - a balanced panel with the flag stays panel-routed and admitted) raise `NotImplementedError` - the RC cohort mass counts UNITS and weights every kept post period by it, a ~T-fold overcount of treated observations on a T-wave RCS (execution-verified); (2) fits declaring a `survey_design=` raise - the realized-mass relay omits the survey mass-uncertainty term and design-aware population-scale totals are not implemented (retained weight scale differs by design family: analytic pweight resolves normalized to sum = n and CS retains no PER-OBSERVATION raw-weight record - the `survey_metadata` scalars report raw-scale `sum_weights`/`weight_range` provenance, but not the per-obs raw vector a population-scale total would need; CS accepts pweight only - while replicate designs retain raw scale); the gate reads the fit-time `is_survey_fit` KIT snapshot, so bare-`cluster=` fits are ADMITTED (their synthesized all-ones internal design is not a survey) and a post-fit edit of the public `survey_metadata` cannot bypass it; the DECLARATION BOUNDARY is deliberate - an explicit unweighted `SurveyDesign(psu=...)` fit is numerically identical to bare `cluster=` yet fails closed by declaration (declaring survey_design opts into the survey contract; conservative, never wrong numbers; the message points at the `cluster=` route); a combined survey+RC fit deterministically gets the RC message; (3) the coincidence check: on bare-`cluster=` fits `_aggregate_simple` weights kept cells by the synthesized cohort masses (full cohort size per kept cell), and when kept cells have INCOMPLETE treated support that realized mass diverges from the complete-case count - the same overcount class as (1) - so the fit raises rather than publishing an ambiguous mass; clean bare-`cluster=` fits (the normal case) are admitted with `C` equal to both counts. Panel-routed pre-upgrade (legacy) kits missing the fit-time snapshots raise the refit message - never a mutable-field or public-replay fallback. (c) **Filter-replay contract:** `C` replays `_aggregate_simple`'s cell selection verbatim over the immutable `agg_gt_cells` fit-time kit snapshot (EDiD snapshot discipline; the kit builder stashes `(g, t, effect, n_treated)` 4-tuples in dict order) - anticipation filter FIRST (cells with `g - anticipation <= t < g` COUNT as post, the business-facing subtlety; universal-base reference cells are excluded by this filter alone, their base period being `< g - anticipation` by construction), finite-effect mask SECOND, weight = cohort mass where `fixed_cohort_agg_weights` provides one else per-cell complete-case `n_treated` (the source's `agg_weight` fallback is deliberately absent - RC-only, gated out), pairwise `np.sum`; the overall scalars come from the same stored fields the `'simple'` relay reads, so a post-fit mutation of `group_time_effects` moves NEITHER factor and `total == C x aggregate('simple').att` cannot desynchronize (pinned by a mutation test). NEVER use `agg_total_weight` (the RC all-units WIF mass). (d) **NaN rules:** empty post set / all-NaN effects give `C = NaN` and an all-NaN row WITHOUT re-emitting the fit-time UserWarnings (the RELAY-level no-re-warn convention — the NaN row is the signal; the recompute levels' bootstrap replay re-emits by design); with finite `C` the relay is VERBATIM - inherited NaNs (a degenerate fit's `(nan, nan)` CI beside finite att/se = 0) pass through, mirroring `aggregate('simple')` per the repo's non-estimable-row convention (att/n are never blanked by NaN inference fields); the ONLY additional blanking is true float overflow (a finite overall value made non-finite BY the `x C` scaling - unreachable in practice, guarded per this code family's finiteness convention). (e) **Bootstrap:** 'total' is a RELAY level - available on bootstrapped fits with the df column NaN (the M-027 per-level policy). (f) **Container conventions:** single row, `level="total"`, `label=["total"]`, `target=["total"]` (a total incremental outcome, NOT an ATT - the single-non-att neutral "estimate" rendering applies), `n = [C]` with `n_kind="obs"`, `weight=[1.0]`, per-class df carrier (CS: `resolve_inference_df` - finite on admitted bare-`cluster=` fits, NaN on plain analytical panel fits). Per-level `n` semantics are deliberately DIFFERENT: 'total''s `n` is the treated observations entering the support, while 'simple''s `n` stays treated+control units by contract - `n_kind` is per-container and each is documented, satisfying the never-conflate rule. (g) **MMM admission:** both `diff_diff.mmm` exporters accept the total container ALONE - `scale` (numeric or `"auto"`) is rejected as double-counting; for overall-total exports this route supersedes `scale="auto"` (see the MMM section). The total remains meaningful only for outcomes additive in levels - the same unverifiable caveat every scale route carries. @@ -1872,7 +1872,7 @@ where `q_{g,e} = pi_g / sum_{g' in G_{trt,e}} pi_{g'}`. - **Note:** `vcov_type` is permanently narrow to `{"hc1"}` per the Chen-Sant'Anna-Xie (2025) EIF-based variance achieving the semiparametric efficiency bound. Analytical-sandwich families `{classical, hc2, hc2_bm}` are rejected at `__init__` — the per-unit EIF aggregation has no equivalent single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined. `cluster=` invokes Liang-Zeger CR1 on cluster-aggregated EIF (`_compute_se_from_eif` with `cluster_indices`); `survey_design=` invokes TSL on the combined IF (`_compute_survey_eif_se`); both live in `diff_diff/efficient_did_aggregation.py` since the M-023 post-fit aggregate() extraction. `vcov_type='conley'` deferred to the EfficientDiD Conley follow-up row in DEFERRED.md. - **Note:** Default `cluster=None` (no survey design) renders summary label "HC1 heteroskedasticity-robust" because the per-unit EIF SE `sqrt(mean(EIF²)/n)` is methodologically HC1-style (no Liang-Zeger G/(G-1) finite-sample correction). `EfficientDiDResults.cluster_name` and `n_clusters` stay None under unclustered fits. This diverges from `ImputationDiD` which auto-clusters at unit per Borusyak-Jaravel-Spiess (2024) Theorem 3 — there the default summary renders the CR1 unit-clustered label. - **Note:** `set_params(vcov_type=bad)` raises immediately on EVERY estimator: since the shared `BaseEstimator` mixin (`diff_diff/_base.py`, v4 2(c)-i), `set_params` validates transactionally by constructor probe re-init, so it enforces exactly `__init__`'s validation, eagerly, library-wide. The former split — EfficientDiD eager vs `ImputationDiD`/`TripleDifference`/`CallawaySantAnna` (and six more: SunAbraham, StackedDiD, StaggeredTripleDifference, SpilloverDiD, TROP, PreTrendsPower) accepting constructor-rejected values until `fit()` — is retired; the fit-time re-validation layers remain as a second check against DIRECT attribute mutation (`est.vcov_type = ...`), which no setter can see. -- **Note (post-fit aggregate() - rows M-023/M-120):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work) in favor of post-fit `EfficientDiDResults.aggregate(type, balance_e=)` - a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a StackedDiD/dCDH view relay): `fit()` computes nothing extra, the results object retains an `AggregationKit`, and `aggregate('event_study'/'group', balance_e=)` re-runs the extracted `_EfficientAggregationMixin` aggregators on a throwaway host while `aggregate('simple')` relays the stored overall row bit-exact. (a) RETAINED BUFFERS (memory contract; phrased as maxima - optional design fields stay None when unsupplied): the per-(g,t) EIF dict, O(n_units x n_gt), the dominant payload - retained on EVERY fit regardless of `store_eif`, which since 3.9 governs only the public `influence_functions` field; `unit_cohorts` (cohort labels), `unit_level_weights`, factorized cluster codes - O(n_units) each; on ordinary (TSL) survey fits the unit-level `ResolvedSurveyDesign` adds `weights` plus, where supplied, `strata`/`psu`/`fpc` (factorized int codes / float values, never raw labels) - up to four O(n_units) arrays; on replicate designs it adds the O(n_units x n_replicates) replicate matrix plus, where supplied, `replicate_strata`/`replicate_rscales` (O(n_replicates)); per-row dict SNAPSHOTS of `group_time_effects` plus copies of the `groups`/`time_periods` lists and the scalar `pt_assumption`/`n_treated+n_control` provenance (aggregate() recomputes exclusively from these private snapshots, never from the mutable public result fields - a user edit of the public rows cannot mix altered point estimates with the retained EIF variance); scalars `n_units`, `cohort_fractions`, and the POST-OVERALL `df_survey` snapshot (captured after the overall inference and before the ES/group gates: the group pass can degenerate the working df to None on replicate designs with `n_valid <= 1`, and every fit-time aggregation seeds from the post-overall value, so recompute replays the exact seed). The data-minimization guarantee is scoped to unit identifiers - no unit-label container is retained. (b) `balance_e` uses the ANCHOR-HORIZON rule (keep cohorts with a finite effect at the anchor horizon, then retain all their horizons) - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference (EDiD anchors on the `int(t - g)` bucket, CS on raw `t - g` - identical on integer-period panels; see the truncation Note below), divergent from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits: 'simple' (and 'total') RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the RECOMPUTE levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` (the fit-captured RNG state + run params BY VALUE - seed=None fits replay; post-fit `set_params`/attribute mutation cannot alter it; pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose` - never bit-identity), the percentile p-value is a count statistic (compared at 2/n_bootstrap), and the replay publishes no analytical provenance (percentile overrides via the shared `bootstrap_utils` appliers; group rows' `df_used` cleared exactly as at fit time; EDiD has no sup-t cband). The spec stamps the weight backend at capture ('rust'/'numpy', or 'portable' for backend-independent branches - stratified survey generation, census-FPC zero weights, the single-PSU degenerate early return); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. Replays RE-EMIT the fit-time bootstrap warnings for the replayed configuration (low n_bootstrap, single-PSU, empty balance_e anchor, non-finite-draw RuntimeWarnings); the relay levels stay silent. Per-call cost O(n_bootstrap x n_units x n_gt), no memoization. The M-027 rationale - no level publishes analytical-provenance fields beside percentile inference - is honored by the percentile overrides and NaN df channels. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the primary ground is the absent joint event-study covariance (container `vcov=None`; the PER-ROW df column now threads the post-overall `df_survey` snapshot via the results-level `event_study_df` channel on analytical survey fits - M-092 completion; None on non-survey/bootstrapped/no-surface fits and for the replicate-undefined 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). Reference semantics are regime-dependent: under `pt_assumption="all"` there is NO reference row (universal first-period baseline; e=-1 is a genuine estimate); under `"post"` the per-cohort baseline cell is materialized as a mechanical zero anchor at `e = -1 - anticipation` whenever it is not the panel's first period, and the MEMBERSHIP-GATED `reference_period` property (the SunAbraham rule - never synthesized when the anchor cell was not estimated) marks it `is_reference` in the container and corrects `plot_event_study`'s inferred reference (previously the `-1` fallback) on PT-Post `anticipation>0` fits. (e) 'simple' relay conventions: `target="att"`, `n = n_treated_units + n_control_units` with `n_kind="units"` (DISJOINT by construction - `last_cohort` trimming reassigns before the counts, so a true total exists, unlike StackedDiD's overlapping sets), `df` = the post-overall snapshot (provenance-exact where `survey_metadata.df_survey` can diverge in the degenerate replicate state); 'group' relay: `n_kind="cells"`, `weight=None` (equal within-cohort weights, no cross-cohort mass), per-row `df_used` array captured at each row's `safe_inference` call (exact by construction; a stated divergence from CS's conservative-min scalar broadcast); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose `survey_metadata` copy carries the snapshot `df_survey` and whose `event_study_df` threads the same snapshot into the per-row df column (cleared on bootstrap replays and for the 0 sentinel). (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C = sum(n_treated)` over the kept post-anticipation FINITE-effect cells of the kit's deep-copied `group_time_effects` snapshot (an exact integer sum; NEVER the routinely-non-integral `n_units x sum(cohort_fractions)` float product). Unweighted fits only: ANY declared `survey_design=` fails closed via the kit's `unit_level_weights`/`resolved_survey_unit` markers (weight-type-agnostic - unweighted psu-only and analytic fweight designs, whose resolved weights stay RAW, gate identically; EDiD never synthesizes an internal design, so the markers are exact declaration provenance and post-fit mutation of the public `survey_metadata` cannot bypass them). No keepers -> NaN mass -> all-NaN row (no re-warn); with finite `C` the relay is verbatim (inherited NaNs pass through; att/n never blanked). Bootstrap fits relay with a NaN df column (M-027); df carrier = the post-overall `df_survey` snapshot; container: single `target='total'` row, `n_kind='obs'`, the per-level n-semantics note in the CS total Note applies. MMM: the container is admitted with NO scale (see the MMM section); the survey/RC totals remainder and its att*dC variance term are the DEFERRED row. +- **Note (post-fit aggregate() - rows M-023/M-120):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3b of the cut: `fit()` computes no event-study/group surface, the legacy `event_study_effects`/`group_effects`/`event_study_df` fields are None after EVERY fit, `to_dataframe(level='event_study'|'group')` raises on fresh fits until the M-093 schema rewire, and `survey_design`/`store_eif` are positional slots 7/8) in favor of post-fit `EfficientDiDResults.aggregate(type, balance_e=)` - a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a StackedDiD/dCDH view relay): `fit()` computes nothing extra, the results object retains an `AggregationKit`, and `aggregate('event_study'/'group', balance_e=)` re-runs the extracted `_EfficientAggregationMixin` aggregators on a throwaway host while `aggregate('simple')` relays the stored overall row bit-exact. (a) RETAINED BUFFERS (memory contract; phrased as maxima - optional design fields stay None when unsupplied): the per-(g,t) EIF dict, O(n_units x n_gt), the dominant payload - retained on EVERY fit regardless of `store_eif`, which since 3.9 governs only the public `influence_functions` field; `unit_cohorts` (cohort labels), `unit_level_weights`, factorized cluster codes - O(n_units) each; on ordinary (TSL) survey fits the unit-level `ResolvedSurveyDesign` adds `weights` plus, where supplied, `strata`/`psu`/`fpc` (factorized int codes / float values, never raw labels) - up to four O(n_units) arrays; on replicate designs it adds the O(n_units x n_replicates) replicate matrix plus, where supplied, `replicate_strata`/`replicate_rscales` (O(n_replicates)); per-row dict SNAPSHOTS of `group_time_effects` plus copies of the `groups`/`time_periods` lists and the scalar `pt_assumption`/`n_treated+n_control` provenance (aggregate() recomputes exclusively from these private snapshots, never from the mutable public result fields - a user edit of the public rows cannot mix altered point estimates with the retained EIF variance); scalars `n_units`, `cohort_fractions`, and the POST-OVERALL `df_survey` snapshot (captured after the overall inference and before the ES/group gates: the group pass can degenerate the working df to None on replicate designs with `n_valid <= 1`, and every fit-time aggregation seeds from the post-overall value, so recompute replays the exact seed). The data-minimization guarantee is scoped to unit identifiers - no unit-label container is retained. (b) `balance_e` uses the ANCHOR-HORIZON rule (keep cohorts with a finite effect at the anchor horizon, then retain all their horizons) - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference (EDiD anchors on the `int(t - g)` bucket, CS on raw `t - g` - identical on integer-period panels; see the truncation Note below), divergent from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits: 'simple' (and 'total') RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the RECOMPUTE levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` (the fit-captured RNG state + run params BY VALUE - seed=None fits replay; post-fit `set_params`/attribute mutation cannot alter it; pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose` - never bit-identity), the percentile p-value is a count statistic (compared at 2/n_bootstrap), and the replay publishes no analytical provenance (percentile overrides via the shared `bootstrap_utils` appliers; group rows' `df_used` cleared exactly as at fit time; EDiD has no sup-t cband). The spec stamps the weight backend at capture ('rust'/'numpy', or 'portable' for backend-independent branches - stratified survey generation, census-FPC zero weights, the single-PSU degenerate early return); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. Replays RE-EMIT the fit-time bootstrap warnings for the replayed configuration (low n_bootstrap, single-PSU, empty balance_e anchor, non-finite-draw RuntimeWarnings); the relay levels stay silent. Per-call cost O(n_bootstrap x n_units x n_gt), no memoization. The M-027 rationale - no level publishes analytical-provenance fields beside percentile inference - is honored by the percentile overrides and NaN df channels. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the primary ground is the absent joint event-study covariance (container `vcov=None`; the PER-ROW df column now threads the post-overall `df_survey` snapshot via the results-level `event_study_df` channel on analytical survey fits - M-092 completion; None on non-survey/bootstrapped/no-surface fits and for the replicate-undefined 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). Reference semantics are regime-dependent: under `pt_assumption="all"` there is NO reference row (universal first-period baseline; e=-1 is a genuine estimate); under `"post"` the per-cohort baseline cell is materialized as a mechanical zero anchor at `e = -1 - anticipation` whenever it is not the panel's first period, and the MEMBERSHIP-GATED `reference_period` property (the SunAbraham rule - never synthesized when the anchor cell was not estimated) marks it `is_reference` in the container and corrects `plot_event_study`'s inferred reference (previously the `-1` fallback) on PT-Post `anticipation>0` fits. (e) 'simple' relay conventions: `target="att"`, `n = n_treated_units + n_control_units` with `n_kind="units"` (DISJOINT by construction - `last_cohort` trimming reassigns before the counts, so a true total exists, unlike StackedDiD's overlapping sets), `df` = the post-overall snapshot (provenance-exact where `survey_metadata.df_survey` can diverge in the degenerate replicate state); 'group' relay: `n_kind="cells"`, `weight=None` (equal within-cohort weights, no cross-cohort mass), per-row `df_used` array captured at each row's `safe_inference` call (exact by construction; a stated divergence from CS's conservative-min scalar broadcast); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose `survey_metadata` copy carries the snapshot `df_survey` and whose `event_study_df` threads the same snapshot into the per-row df column (cleared on bootstrap replays and for the 0 sentinel). (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C = sum(n_treated)` over the kept post-anticipation FINITE-effect cells of the kit's deep-copied `group_time_effects` snapshot (an exact integer sum; NEVER the routinely-non-integral `n_units x sum(cohort_fractions)` float product). Unweighted fits only: ANY declared `survey_design=` fails closed via the kit's `unit_level_weights`/`resolved_survey_unit` markers (weight-type-agnostic - unweighted psu-only and analytic fweight designs, whose resolved weights stay RAW, gate identically; EDiD never synthesizes an internal design, so the markers are exact declaration provenance and post-fit mutation of the public `survey_metadata` cannot bypass them). No keepers -> NaN mass -> all-NaN row (no re-warn); with finite `C` the relay is verbatim (inherited NaNs pass through; att/n never blanked). Bootstrap fits relay with a NaN df column (M-027); df carrier = the post-overall `df_survey` snapshot; container: single `target='total'` row, `n_kind='obs'`, the per-level n-semantics note in the CS total Note applies. MMM: the container is admitted with NO scale (see the MMM section); the survey/RC totals remainder and its att*dC variance term are the DEFERRED row. - **Note (fractional-period truncation bucketing):** the event-study equation defines `ES(e)` at an exact relative time, but the implementation buckets horizons by `int(t - g)` - truncation TOWARD ZERO - on the analytical aggregator, the bootstrap ES prep (all three keying sites, the `balance_e` anchor filter included; aligned 2026-08), and `hausman_pretest`'s internal aggregation. On integer-period panels `int()` is the identity and nothing below applies. On fractional-period panels (accepted by `fit()` without an integrality check): (i) bucket 0 is DOUBLE-WIDTH, spanning `(-1, 1)`, so the pre-treatment horizon e=-0.5 pools into the post-treatment e=0 bucket; e=1.0/1.5 pool into bucket 1 and e=-1.5/-1.0 into bucket -1; (ii) under `pt_assumption="post"` the bucket at `-1 - anticipation` pools genuine estimated fractional pre-treatment horizons (e.g. raw e=-1.5) with the mechanical zero anchor, and the container's reference normalization then publishes the WHOLE pooled row as `att=0.0` with NaN inference (`is_reference=True`) - genuine fractional-horizon estimates are subsumed into the reference row (the fit-time `event_study_effects` dict keeps the pooled effect + percentile inference unrewritten; the rewrite is container-only); (iii) WEIGHTING is CELL-MASS within the bucket - the aggregator appends one `(effect, pi_g)` term per CELL and normalizes, so a cohort contributing k cells to a bucket carries k*pi_g mass, whereas the exact-relative-time equation has one term per cohort - while `n_groups` counts DISTINCT cohorts in the bucket (identity with the cell count on integer panels). Every aggregation that truncation-buckets a fractional horizon emits a UserWarning naming this Note. - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. @@ -2467,7 +2467,7 @@ The pooled estimator is `DID^{CBWSDID}_e = Σ_a (N^D_a/N^D_Ω)(Δ̄^D_{a,e} − - **Note:** Provenance exposure (4.0 program row M-092, no numeric change): the event-time sub-block of the pooled-regression coefficient covariance is now persisted as `StackedDiDResults.event_study_vcov` (+ `event_study_vcov_index` = the estimated event times; the synthesized reference period is not a regression column and is absent). Persisted in every inference mode - analytical hc1/hc2_bm sandwich, survey replicate refit, and survey TSL all reassign the coefficient covariance the reported ES SEs are read from, so the marginal SEs are always this matrix's diagonal. `event_study_df` records, per event time, the df actually passed to `safe_inference` (the per-event Bell-McCaffrey Satterthwaite contrast df under `hc2_bm`, the scalar survey df under survey designs, NaN on the normal-theory hc1 path and on hc2_bm fail-closed rows). DiagnosticReport's parallel-trends check consumes the persisted VCV via its joint-Wald path for hc1/survey fits; `hc2_bm` fits are routed to Bonferroni over the BM-adjusted per-row p-values instead (the chi-square Wald reference would discard the CR2/BM small-sample correction; multi-constraint AHT/HTZ CR2 testing is tracked in DEFERRED.md - see REPORTING.md "hc2_bm parallel-trends policy"). - **Note:** Inference is conditional-on-the-estimated-weights cluster-robust (the existing `hc1`/`hc2_bm` path with `W_{sa}` as the WLS weights) — the paper's default. The paper's weight-re-estimating bootstrap is NOT implemented in v1 (deliberate scope; entropy balancing is smooth so the Abadie–Imbens (2008) nonsmooth-matching bootstrap caveat does not apply). `cluster` is orthogonal to `b_{sa}` (weights conditioned-on); default `unit` matches the paper. - **Note:** v1 scope — only `balance="entropy"` with `weighting="aggregate"`. `balance` + `population`/`sample_share` and `balance` + `survey_design=` raise `NotImplementedError`; matching-based balancing and the repeated `0→1/1→0` episode extension are out of scope. -- **Note (post-fit aggregate() - row M-024):** `fit(aggregate=)` is deprecated (3.9; removed 4.0) and the event-study surface is ALWAYS materialized at fit — the pooled regression always included the event-time interactions, so only the extraction and, under `hc2_bm`, the per-event Bell-McCaffrey contrast DOFs were fit-time-gated; both gates are gone and the param is behaviorally inert (its `group`/`all`/unknown ValueErrors persist after the shim warning). `aggregate('event_study')` returns the unified `EventStudyResults` container (a pure view; no recomputation, so every stored inference mode — survey TSL, replicate refit, hc2_bm fail-close — relays faithfully); `aggregate('simple')` relays `overall_att/se/t/p/CI` bit-exactly with `target="att"` (the scalar is the equally-weighted post-period average, NOT the per-event-time trimmed aggregate ATT `θ^e_κ` — `describe_target_parameter` remains the estimand's prose source of truth), `n = n_treated_units` with `n_kind="units"` (TREATED-only scope: under dynamic clean-control definitions a later-treated unit is treated in its own sub-experiment and a clean control in earlier ones, so the treated and control unit sets OVERLAP and no disjoint total exists — cross-container `n` comparisons vs CallawaySantAnna's treated+control convention are out of contract), and `df = inference_df` (the stored overall inference's exact provenance). Consequences of the always-computed surface: plain-fit `summary()` renders the event-study table, `to_dataframe('event_study')` is total on 3.9+ fits, `plot_event_study` renders plain fits, DiagnosticReport's `heterogeneity` (always) and `parallel_trends` (`kappa_pre >= 2`) checks run on plain fits, and the `hc2_bm` overall-ATT contrast now shares the m>1 batch of `_compute_cr2_bm_contrast_dof`, whose degeneracy guard is batch-relative — the noise-floor carve-out on the `Wald_test(test="HTZ")$df_denom` parity claim in the variance-families bullets above widens quantitatively (unchanged behavior when tripped: fail-closed NaN inference plus linalg's loud noise-floor UserWarning; the absolute floor already made this reachable at m=1). +- **Note (post-fit aggregate() - row M-024):** `fit(aggregate=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3b of the cut; its `group`/`all`/unknown ValueErrors died with it - `aggregate('group')` already fails closed in the mixin - and `population`/`survey_design`/`covariates` are positional slots 6/7/8) and the event-study surface is ALWAYS materialized at fit — the pooled regression always included the event-time interactions, so only the extraction and, under `hc2_bm`, the per-event Bell-McCaffrey contrast DOFs were fit-time-gated; both gates are gone (the param had been behaviorally inert since 3.9). `aggregate('event_study')` returns the unified `EventStudyResults` container (a pure view; no recomputation, so every stored inference mode — survey TSL, replicate refit, hc2_bm fail-close — relays faithfully); `aggregate('simple')` relays `overall_att/se/t/p/CI` bit-exactly with `target="att"` (the scalar is the equally-weighted post-period average, NOT the per-event-time trimmed aggregate ATT `θ^e_κ` — `describe_target_parameter` remains the estimand's prose source of truth), `n = n_treated_units` with `n_kind="units"` (TREATED-only scope: under dynamic clean-control definitions a later-treated unit is treated in its own sub-experiment and a clean control in earlier ones, so the treated and control unit sets OVERLAP and no disjoint total exists — cross-container `n` comparisons vs CallawaySantAnna's treated+control convention are out of contract), and `df = inference_df` (the stored overall inference's exact provenance). Consequences of the always-computed surface: plain-fit `summary()` renders the event-study table, `to_dataframe('event_study')` is total on 3.9+ fits, `plot_event_study` renders plain fits, DiagnosticReport's `heterogeneity` (always) and `parallel_trends` (`kappa_pre >= 2`) checks run on plain fits, and the `hc2_bm` overall-ATT contrast now shares the m>1 batch of `_compute_cr2_bm_contrast_dof`, whose degeneracy guard is batch-relative — the noise-floor carve-out on the `Wald_test(test="HTZ")$df_denom` parity claim in the variance-families bullets above widens quantitatively (unchanged behavior when tripped: fail-closed NaN inference plus linalg's loud noise-floor UserWarning; the absolute floor already made this reachable at m=1). - **Note (container admission - rows M-024/M-093):** `compute_honest_did` and `compute_pretrends_power` admit Stacked-sourced `aggregate('event_study')` containers. Provenance: `base_period` is the class-level constant `"universal"` (every sub-experiment normalizes against the single omitted reference `e = -1 - anticipation`) and `reference_event_times` is the singleton `(-1 - anticipation,)`. Requirements: `kappa_pre >= 2` (the default `kappa_pre=1` grid minus the reference has ZERO estimated pre-periods — both consumers reject), and for HonestDiD a non-singular FULL retained event-study covariance — the validated sub-block spans the retained pre AND post rows (`allow_singular=False` per Rambachan-Roth's eigenvalues-bounded-away-from-zero assumption on the full Σ — keep `kappa_pre` small relative to the cluster count; PreTrendsPower keeps its documented singular support). The container's scalar `df_survey` is None on analytical fits — the generic resolver's fallback reads `df_inference`, a name StackedDiD does not carry (`inference_df` is the overall POST-AVERAGE contrast df, not a design-level cluster df, and misusing it for FLCI critical values would be worse than the asymptotic default) — so honest FLCI critical values are NORMAL-THEORY on analytical Stacked fits, diverging from bare-`cluster=` CS fits (which thread `G-1`); survey fits thread the survey df and replicate-undefined designs the fail-closed `0.0` sentinel (NaN FLCI CIs; the identified-set `lb`/`ub` bounds are df-independent and stay finite). Withheld-inference rows (finite `se`, non-finite `p_value` — the hc2_bm BM-DOF fail-close and replicate-undefined designs both emit this shape) are ADMITTED with a source-scoped warning in BOTH consumers: bounds/power consume only the point estimates and covariance, which remain valid; the warning is shape-descriptive because containers are publicly constructible. A rank-dropped interior event-time column (NaN-se row) fails closed via honest's consecutive-grid ValueError with a Stacked-specific remedy (no `balance_e` — Stacked's aggregate() has no such level). REFERENCE-SUPPORT GUARD: `fit()` raises when either omitted-reference cell (treated / control) carries no POSITIVE COMPOSED WLS WEIGHT — Equation 3 is Q-weighted, so raw row presence is not support: a gapped panel can empty the cell outright (IC1 checks only min/max periods), aggregate Q-weights zero a sub-experiment's controls at any event time where that sub-experiment has no treated rows, and survey pweights can zero a cell — without the guard the design rank-drops and QR pivoting silently re-normalizes against an arbitrary horizon while the surface certifies `delta_0 = 0` at the reference. Partial support (some cohorts' own ref period absent while the POOLED omitted category is populated) still fits: the coefficient-vector normalization is real; the per-cohort baseline raggedness is estimand composition, the documented ragged-window behavior. *Covariate-balancing edge cases:* @@ -4858,7 +4858,7 @@ Under `survey_design=SurveyDesign(weights, strata, psu, fpc)`, the variance comp **Reduction invariant**: at `H=1`, the sup collapses to the marginal and `q → Φ⁻¹(1 - alpha/2) ≈ 1.96` at `alpha=0.05` up to MC noise. Locked by `TestSupTReducesToNormalAtH1` (G=500, B=5000, seed=42, `atol=0.15` on the quantile), its clustered variant `test_clustered_sup_t_h1_reduces_to_normal` (both the continuous scale-1.0 and mass-point `√(G/(G-1))` scalars), and `TestEventStudySurveyCband::test_trivial_survey_h1_sup_t_matches_analytical` / `test_stratified_h1_sup_t_matches_analytical` for the trivial-survey and stratified cases respectively. -**Scope**: sup-t bootstrap runs in the event-study mode (multi-period panels; the deprecated `aggregate="event_study"` override maps to it, row M-027) when `cband=True` (default) AND either (a) `survey_design=` is supplied (the survey band) OR (b) `cluster=` is supplied (the clustered band — see "Note (HAD clustered event-study sup-t band)"). An unweighted, unclustered event-study skips the bootstrap entirely — pre-Phase 4.5 B numerical output bit-exactly preserved. Setting `cband=False` disables the bootstrap on any path. +**Scope**: sup-t bootstrap runs in the event-study mode (multi-period panels; the mode is selected from the panel shape - the fit-time `aggregate=` override was removed at 4.0, PR 3b, row M-027) when `cband=True` (default) AND either (a) `survey_design=` is supplied (the survey band) OR (b) `cluster=` is supplied (the clustered band — see "Note (HAD clustered event-study sup-t band)"). An unweighted, unclustered event-study skips the bootstrap entirely — pre-Phase 4.5 B numerical output bit-exactly preserved. Setting `cband=False` disables the bootstrap on any path. - **Deviation from shared survey-bootstrap contract:** `_sup_t_multiplier_bootstrap` raises `NotImplementedError` on `SurveyDesign(lonely_psu="adjust")` with singleton strata. The shared `generate_survey_multiplier_weights_batch` helper pools singleton PSUs into a pseudo-stratum with NONZERO multipliers, but `compute_survey_if_variance` centers singleton PSU scores at the GLOBAL mean of PSU scores (rather than the pseudo-stratum mean). Matching the two would require a pooled-singleton pseudo-stratum centering transform in the HAD sup-t path that has not been derived. The HAD-specific limitation is scoped to: weighted event-study + `cband=True` + `lonely_psu="adjust"` + at least one singleton stratum. Practitioners can use `lonely_psu="remove"` or `"certainty"` (matches the analytical target bit-exactly on the HAD sup-t path), or pass `cband=False` to skip the simultaneous band. All other survey-bootstrap consumers (CallawaySantAnna, dCDH, SDID) retain full `lonely_psu="adjust"` support through the shared helper. @@ -4982,15 +4982,15 @@ Shipped in `diff_diff/had_pretests.py` as `yatchew_hr_test()`. Alternative to St - **Note (mean-independence null is the R-parity extension, not paper-derived):** The paper (Appendix E, Theorem 7) defines `yatchew_hr_test` only under the linearity null. The `null="mean_independence"` mode mirrors R `YatchewTest::yatchew_test(order=0)` and is used by R `DIDHAD::did_had(yatchew=TRUE)` on placebo rows ("non-parametric pre-trends test" per the package README). Exposed for parity coverage of the placebo-Yatchew rows in `tests/test_did_had_parity.py::TestYatchewParity` (PR #397). The default `"linearity"` mode is paper-derived; users invoking `"mean_independence"` are running an R-parity extension, not a paper-prescribed test. *Four-step pre-testing workflow (Section 4.2-4.3):* -Shipped as `did_had_pretest_workflow()` in Phase 3 (two-period `aggregate="overall"`) and extended in the Phase 3 follow-up with `aggregate="event_study"` dispatch that closes the step-2 pre-trends gap on multi-period panels. The paper's decision rule for TWFE reliability in HADs: +Shipped as `did_had_pretest_workflow()` in Phase 3 (two-period panels, the overall battery) and extended in the Phase 3 follow-up with the multi-period event-study dispatch that closes the step-2 pre-trends gap on multi-period panels. The battery is selected from the panel shape (`_infer_aggregate_mode`; the `aggregate=` keyword that used to select it was deprecated in 3.9 and removed at 4.0, row M-139) - the mode names below (`"overall"`/`"event_study"`) survive only as the `HADPretestReport.aggregate` output field. The paper's decision rule for TWFE reliability in HADs: 1. Test the null of a QUG (`H_0: d̲ = 0`) using `qug_test()`. 2. Run a pre-trends test of Assumption 7 (requires at least one earlier pre-period). 3. Test that `E(ΔY | D_2)` is linear (`stute_test` or `yatchew_hr_test`; or the joint Stute variants below in event-study dispatch). 4. If NONE of the three is rejected, `β̂_{fe}` from TWFE may be used to estimate the treatment effect. -**Phase 3 delivery (`aggregate="overall"`, two-period):** `did_had_pretest_workflow()` runs steps 1 + 3 (QUG + Stute + Yatchew). Step 2 is NOT run on this path because a two-period panel has no pre-period placebo horizon to test against; the verdict explicitly flags the Assumption 7 gap via the "paper step 2 deferred" caveat. +**Phase 3 delivery (two-period panel, overall battery):** `did_had_pretest_workflow()` runs steps 1 + 3 (QUG + Stute + Yatchew). Step 2 is NOT run on this path because a two-period panel has no pre-period placebo horizon to test against; the verdict explicitly flags the Assumption 7 gap via the "paper step 2 deferred" caveat. -**Phase 3 follow-up delivery (`aggregate="event_study"`, multi-period):** `did_had_pretest_workflow(..., aggregate="event_study")` dispatches on a balanced ≥3-period panel. Runs QUG at `F` + joint pre-trends Stute across earlier pre-periods (step 2, mean-independence null) + joint homogeneity-linearity Stute across post-periods (step 3 joint extension, linearity null). Step 2 closure requires at least TWO pre-periods (the base pre-period plus one earlier placebo); on panels with only a single pre-period (the base `F-1`) the workflow emits `pretrends_joint=None` and the verdict flags the skip ("joint pre-trends skipped (no earlier pre-period)"). `all_pass` is False in this degenerate case. The verdict on the event-study path does NOT emit the "paper step 2 deferred" caveat when step 2 runs. +**Phase 3 follow-up delivery (multi-period panel, event-study battery):** `did_had_pretest_workflow()` dispatches the event-study battery on a balanced ≥3-period panel. Runs QUG at `F` + joint pre-trends Stute across earlier pre-periods (step 2, mean-independence null) + joint homogeneity-linearity Stute across post-periods (step 3 joint extension, linearity null). Step 2 closure requires at least TWO pre-periods (the base pre-period plus one earlier placebo); on panels with only a single pre-period (the base `F-1`) the workflow emits `pretrends_joint=None` and the verdict flags the skip ("joint pre-trends skipped (no earlier pre-period)"). `all_pass` is False in this degenerate case. The verdict on the event-study path does NOT emit the "paper step 2 deferred" caveat when step 2 runs. *Algorithm variant - Joint Stute tests (Section 4.2-4.3 joint; Phase 3 follow-up 2026-04):* Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in core) plus two thin data-in wrappers `joint_pretrends_test()` (mean-independence null) and `joint_homogeneity_test()` (linearity null). Generalizes the single-horizon Stute CvM (above) to K horizons with joint inference. @@ -5003,12 +5003,12 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - `joint_homogeneity_test(post_periods, base_period)`: `null_form="linearity"`, design matrix `[1, D]`; residuals from `OLS(Y_t - Y_base ~ 1 + D)` per post-period (paper Section 4.3 page 32 joint across post-periods, Pierce-Schott reports p=0.40). - **Note:** Sum-of-CvMs aggregation is a standard joint specification-test construction (Delgado 1993; Escanciano 2006); the paper does not prescribe an aggregation rule. Sum-of-CvMs balances power across diffuse vs concentrated alternatives and bootstraps cleanly with shared-η. - **Note:** Event-study dispatch adjudicates step 3 via joint Stute only; there is no joint Yatchew variant because the paper does not derive one. The overall two-period path still uses the Phase 3 "Stute OR Yatchew" adjudication. Users who need Yatchew-style adjacent-difference variance-ratio robustness under multi-period data can run `yatchew_hr_test` on each (base, post) pair manually. -- **Note (Phase 4 — Eq 17 / Eq 18 linear-trend detrending shipped):** `trends_lin: bool = False` (keyword-only) on `HeterogeneousAdoptionDiD.fit(aggregate="event_study")`, `joint_pretrends_test`, and `joint_homogeneity_test` (PR #389, 2026-04). Mirrors R `DIDHAD::did_had(..., trends_lin=TRUE)` (Credible-Answers/did_had v2.0.0, SHA `edc09197`). Per-group linear-trend slope estimated as `Y[g, F-1] - Y[g, F-2]` and applied as `(t - base) × slope` adjustment to per-event-time outcome evolutions (HAD.fit) or to `Y[g, t] - Y[g, base]` directly (joint pretests). The "consumed" placebo at our event-time `e=-2` is auto-dropped (R reduces max placebo lag by 1 with the same effect). Requires F ≥ 3 / `base_period - 1` in panel — front-door `ValueError` if not. Mutually exclusive with survey weighting (raises `NotImplementedError` per `feedback_per_method_survey_element_contract`; weighted slope estimator not derived from paper). Pierce-Schott published-number replication (paper p=0.51 / p=0.40 anchors) deferred indefinitely — primary analysis panel is LBD-restricted (Census FSRDC); the public-deposit proxy panel has filtering ambiguity that prevents exact published-number parity. Replaced by end-to-end R-package parity below, which is a strictly stronger correctness signal. +- **Note (Phase 4 — Eq 17 / Eq 18 linear-trend detrending shipped):** `trends_lin: bool = False` (keyword-only) on `HeterogeneousAdoptionDiD.fit()` (multi-period panels), `joint_pretrends_test`, and `joint_homogeneity_test` (PR #389, 2026-04). Mirrors R `DIDHAD::did_had(..., trends_lin=TRUE)` (Credible-Answers/did_had v2.0.0, SHA `edc09197`). Per-group linear-trend slope estimated as `Y[g, F-1] - Y[g, F-2]` and applied as `(t - base) × slope` adjustment to per-event-time outcome evolutions (HAD.fit) or to `Y[g, t] - Y[g, base]` directly (joint pretests). The "consumed" placebo at our event-time `e=-2` is auto-dropped (R reduces max placebo lag by 1 with the same effect). Requires F ≥ 3 / `base_period - 1` in panel — front-door `ValueError` if not. Mutually exclusive with survey weighting (raises `NotImplementedError` per `feedback_per_method_survey_element_contract`; weighted slope estimator not derived from paper). Pierce-Schott published-number replication (paper p=0.51 / p=0.40 anchors) deferred indefinitely — primary analysis panel is LBD-restricted (Census FSRDC); the public-deposit proxy panel has filtering ambiguity that prevents exact published-number parity. Replaced by end-to-end R-package parity below, which is a strictly stronger correctness signal. - **Note (R-package end-to-end parity, PR #389):** Validated against `DIDHAD` v2.0.0 (Credible-Answers/did_had, SHA `edc09197`) on the **`design="continuous_at_zero"` (Design 1') surface**, on 3 paper-derived synthetic DGPs (Uniform, Beta(2,2), Beta(0.5,1)) × 5 method combinations (overall, event-study, placebo, yatchew, trends_lin). Generator: `benchmarks/R/generate_did_had_golden.R`; fixture: `benchmarks/data/did_had_golden.json`; test: `tests/test_did_had_parity.py`. **Scope qualifier (PR #392 R8 P3):** the harness explicitly forces `HeterogeneousAdoptionDiD(design="continuous_at_zero")` because R `did_had` always evaluates the local-linear at `d=0` regardless of dose distribution. Our default `design="auto"` may legitimately resolve to `continuous_near_d_lower` (`d_lower=d.min()`, Design 1) or `mass_point` (Design 2) on dose distributions with boundary density bounded away from zero (e.g., Beta(2,2) at G=200), in which case the WAS estimand evaluates at a different point and diverges from R's `did_had` numerically. That divergence is methodologically defensible — our auto-detect uses more information when boundary mass is sparse — but is out of scope for this parity contract. Tolerances: point estimate / SE / CI bounds at `atol=1e-8`; closed-form Yatchew T-stat at `atol=1e-10` after a documented `× G/(G-1)` finite-sample convention shift. Two intentional convention deviations from R: **(a)** we report the **bias-corrected** point estimate `att = (mean(ΔY) - tau.bc) / mean(D)` (modern CCF 2018 convention); R's `Estimate` column reports the **conventional** estimate `(mean(ΔY) - tau.us) / mean(D)` with the bias-corrected CI separately — our `att` matches R's CI midpoint, our `se` / `conf_int_low` / `conf_int_high` match R's `se` / `ci_lo` / `ci_hi` directly. **(b)** Our `yatchew_hr_test` follows paper Appendix E's literal `(1/G)` and `(1/(2G))` variance-denominator convention; R's `YatchewTest::yatchew_test` uses base-R `var()`'s `(1/(N-1))` sample-variance convention. Ratio is exactly `N/(N-1)`; both converge to the same asymptotic null distribution. Yatchew on placebos with R's mean-independence null (`order=0`, fits `Y ~ 1`) is exposed via `yatchew_hr_test(null="mean_independence")` (added post-PR #392). The parity test routes effect rows through `null="linearity"` (R `order=1`) and placebo rows through `null="mean_independence"` (R `order=0`); both modes share the same `(1/G)` vs `(1/(N-1))` finite-sample convention shift and parity holds at `atol=1e-10` after the documented `× G/(G-1)` transform. - **Note:** Horizon labels in `StuteJointResult.horizon_labels` are `str(t)` verbatim and carry STRING IDENTITY ONLY — NOT a chronological ordering key. Callers who need chronological order must preserve the original period values alongside (e.g. from the `pre_periods` / `post_periods` argument). - **Note:** NaN propagation is explicit: when any horizon has NaN in residuals, `cvm_stat_joint=NaN`, `p_value=NaN`, `reject=False`, AND `per_horizon_stats={label: np.nan for every horizon}` (full dict preserved with NaN values — not empty, not partial). -**Phase 3 follow-up delivery:** `stute_joint_pretest()`, `joint_pretrends_test()`, `joint_homogeneity_test()`, `StuteJointResult`, and `did_had_pretest_workflow(aggregate="event_study")` shipped together in PR #353 (2026-04). The `practitioner_next_steps()` HAD handlers landed in Phase 5 wave 1 (PR #402); the T21 HAD pretest workflow tutorial landed in PR #409 (Phase 5 wave 2 first slice). The T22 survey-weighted HAD tutorial (`docs/tutorials/22_had_survey_design.ipynb`) shipped as the follow-up to PR #432 (2026-05). +**Phase 3 follow-up delivery:** `stute_joint_pretest()`, `joint_pretrends_test()`, `joint_homogeneity_test()`, `StuteJointResult`, and the multi-period `did_had_pretest_workflow()` dispatch shipped together in PR #353 (2026-04). The `practitioner_next_steps()` HAD handlers landed in Phase 5 wave 1 (PR #402); the T21 HAD pretest workflow tutorial landed in PR #409 (Phase 5 wave 2 first slice). The T22 survey-weighted HAD tutorial (`docs/tutorials/22_had_survey_design.ipynb`) shipped as the follow-up to PR #432 (2026-05). **Reference implementation(s):** - R: `did_had` (de Chaisemartin, Ciccia, D'Haultfœuille, Knau 2024a); `stute_test` (2024c); `yatchew_test` (Online Appendix, Table 3). @@ -5021,11 +5021,11 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - **Note:** Equal-weighting on the continuous path. Paper does not prescribe a unit-weighting scheme on the continuous local-linear paths. Library uses per-unit equal weighting (`w_g = 1` default, matching `diff_diff/_nprobust_port.lprobust`'s default), NOT dose-cell-size weights. Practical consequence: WAS is the population-mean slope from Eq. 3 — `[E(ΔY) − lim_{d↓d̲} E(ΔY | D ≤ d)] / E(D)` (computed as `att = (mean(ΔY) − τ_bc) / mean(D)`), not a cell-size-weighted average; with cell-size weighting, units in less-densely-populated regions of the dose distribution would contribute disproportionately to the boundary slope. User-supplied weights (via `survey_design=SurveyDesign(weights=...)`, pweight) override the equal-weight default and thread through as `W_combined = k((D − d̲)/h) · w_g`. Lock in `tests/test_methodology_had.py::TestHADDeviations::test_equal_weighting_is_per_row_not_per_dose_cell`. - **Note:** Sup-t bootstrap gating. Simultaneous-band sup-t multiplier bootstrap runs in the event-study mode when `cband=True` (default) AND either `survey_design=` is supplied (survey band) OR `cluster=` (cluster-robust band — fires even on an unweighted fit, Phase 2b). The unweighted, unclustered event-study path bit-exactly preserves pre-Phase 4.5 B numerical output (stability invariant). Setting `cband=False` disables the bootstrap on any path. See the algorithmic contract above at `_sup_t_multiplier_bootstrap`. -- **Note (post-fit aggregate() + mode inference - rows M-027/M-139):** `fit(aggregate=)` and `did_had_pretest_workflow(aggregate=)` are deprecated in 3.9 (removed in 4.0). UNLIKE every prior aggregate-postfit adopter, the param was a MODE SELECTOR over mutually exclusive panel shapes ('overall' = exactly two periods -> `HeterogeneousAdoptionDiDResults`; 'event_study' = more than two -> `HeterogeneousAdoptionDiDEventStudyResults`), so the successor is panel-shape INFERENCE: both surfaces resolve the mode from the shared `_infer_aggregate_mode` helper (distinct time values <= 2 -> overall, else event_study), running after the deprecated column-alias reconciliation (the M-035..M-039 renamed-kwarg shims). BEHAVIOR DELTA: a plain multi-period `fit()` (or workflow call) previously raised the two-period shape error and now runs the event-study mode - error -> works; no numbers change on any previously-working call, and a supplied legacy value warns then runs the legacy routing unchanged (invalid values still raise; the one sentinel-reachable shape error is the staggered last-cohort auto-filter dropping a T>2 panel below three periods, its message reworded to panel-shape vocabulary). POST-FIT `aggregate()` is a PURE VIEW with per-class supported subsets - the overall class supports only `'simple'` (one-row bit-exact relay: `target = target_parameter`, the estimand label "WAS"/"WAS_d_lower"; `n = n_obs` contributing units with `n_kind='units'` - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band at the `d_lower` boundary, so `n_obs` is the single source; `df = resolve_inference_df`, provenance-exact: the finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable) and the event-study class supports only `'event_study'` (a `build_event_study_surface` passthrough via the `_from_had` adapter, cband fields included; the adapter's `n_kind` was corrected "obs" -> "units" with this row - `n_obs_per_horizon` counts units; the adapter now also threads the scalar `event_study_df` - the unit-level design's survey df governing every horizon's t-inference - into the container's per-row df column, M-092 completion; None on non-survey fits, and no bootstrap gate applies since pointwise inference is always analytical). Cross-mode levels fail closed (the two modes share no computable estimand; joint cross-horizon covariance is the DEFERRED.md row). NO kit is retained (views work on pickles from any release) and NO bootstrap gate exists (`n_bootstrap` feeds only the sup-t cband critical value; pointwise inference is always analytical). `AggregationResult.summary()` gained the single-non-'att'-target rendering with this row (target column + neutral 'estimate' heading, column sized to the longest label - previously a WAS or dCDH estimand row rendered under a hard-coded 'ATT' heading; uniform-'att' and ContinuousDiD att/acrt output byte-stable). CONSUMER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (see M-093): HAD's event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (its coefficient is identically zero AND the WAS is not identified there) and no joint covariance exists; both terminal TypeErrors state the deferral in exactly that wording. The `HADPretestReport.aggregate` FIELD survives as output metadata recording which battery ran (only the params die). +- **Note (post-fit aggregate() + mode inference - rows M-027/M-139):** `fit(aggregate=)` and `did_had_pretest_workflow(aggregate=)` were deprecated in 3.9 and REMOVED at 4.0 (PR 3b of the cut: `_VALID_AGGREGATES` deleted from `had.py` and `had_pretests.py`, `_infer_aggregate_mode` is the sole selector shared by both surfaces, and no positional slot shifts - everything after the removed `fit()` slot is keyword-only, so a seventh positional now raises natively). UNLIKE every prior aggregate-postfit adopter, the param was a MODE SELECTOR over mutually exclusive panel shapes ('overall' = exactly two periods -> `HeterogeneousAdoptionDiDResults`; 'event_study' = more than two -> `HeterogeneousAdoptionDiDEventStudyResults`), so the successor is panel-shape INFERENCE: both surfaces resolve the mode from the shared `_infer_aggregate_mode` helper (distinct time values <= 2 -> overall, else event_study), running after the column-name reconciliation (the M-035..M-039 renames, whose shims were removed at PR 2). BEHAVIOR DELTA: a plain multi-period `fit()` (or workflow call) previously raised the two-period shape error and now runs the event-study mode - error -> works; no numbers change on any previously-working call; since 4.0 a supplied value is a `TypeError` (the one shape error still reachable is the staggered last-cohort auto-filter dropping a T>2 panel below three periods, its message worded in panel-shape vocabulary). POST-FIT `aggregate()` is a PURE VIEW with per-class supported subsets - the overall class supports only `'simple'` (one-row bit-exact relay: `target = target_parameter`, the estimand label "WAS"/"WAS_d_lower"; `n = n_obs` contributing units with `n_kind='units'` - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band at the `d_lower` boundary, so `n_obs` is the single source; `df = resolve_inference_df`, provenance-exact: the finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable) and the event-study class supports only `'event_study'` (a `build_event_study_surface` passthrough via the `_from_had` adapter, cband fields included; the adapter's `n_kind` was corrected "obs" -> "units" with this row - `n_obs_per_horizon` counts units; the adapter now also threads the scalar `event_study_df` - the unit-level design's survey df governing every horizon's t-inference - into the container's per-row df column, M-092 completion; None on non-survey fits, and no bootstrap gate applies since pointwise inference is always analytical). Cross-mode levels fail closed (the two modes share no computable estimand; joint cross-horizon covariance is the DEFERRED.md row). NO kit is retained (views work on pickles from any release) and NO bootstrap gate exists (`n_bootstrap` feeds only the sup-t cband critical value; pointwise inference is always analytical). `AggregationResult.summary()` gained the single-non-'att'-target rendering with this row (target column + neutral 'estimate' heading, column sized to the longest label - previously a WAS or dCDH estimand row rendered under a hard-coded 'ATT' heading; uniform-'att' and ContinuousDiD att/acrt output byte-stable). CONSUMER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (see M-093): HAD's event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (its coefficient is identically zero AND the WAS is not identified there) and no joint covariance exists; both terminal TypeErrors state the deferral in exactly that wording. The `HADPretestReport.aggregate` FIELD survives as output metadata recording which battery ran (only the params die). - **Note:** Pierce-Schott (2016) Figure 2 replication harness deferred. The paper's empirical application self-acknowledges (Section 5.2; mirrored in `dechaisemartin-2026-review.md:321`) that "NP estimators are too noisy to be informative" on the LBD-restricted PNTR panel. R parity at `atol=1e-8` on 3 DGPs × 5 method combos via `tests/test_did_had_parity.py` (bit-exact, `rtol=0`) is a stronger correctness anchor than reproducing pointwise CIs on LBD-restricted data. **Scope caveat:** R parity locks point estimate, SE, and CI bounds bit-exactly to R's bounds — it does NOT independently verify the asymptotic-coverage properties of the bias-corrected CI in small samples. Paper Table 1 documents under-coverage at small G (89% at G=100 on DGP 1, 93% at G=500, 95% at G=2500); this is inherited from the CCF asymptotic theory itself, and Python is exact-parity with R at the limit-law machinery. - **Note:** Table 1 coverage-rate reproduction deferred. Paper Section 3.1.5 reports 2,000-iter Monte Carlo coverage rates at `G ∈ {100, 500, 2500}` on DGPs 1/2/3. The existing `tests/test_did_had_parity.py` R parity at `atol=1e-8` on the same 3 DGPs reproduces the exact point estimate and SE algorithm to bit-exact tolerance; coverage-rate MC would re-verify the CCF asymptotic coverage already pinned by R parity (Python ≡ R ≡ paper) at the sample-mean level. **Scope caveat (mirrors above):** R parity does NOT re-prove asymptotic-coverage at small G; paper Table 1's 89% / 93% / 95% under-coverage band is valid for both R and Python. - **Library extension:** Staggered-timing fail-closed. Paper Appendix B.2 prescribes "Warn" when staggered treatment timing is detected; library raises `ValueError` at `diff_diff/had.py:1624` when multiple first-treat cohorts are detected without `first_treat`. Library extension toward stricter safety: `UserWarning` would let the silent-misuse bug class through (HAD's Appendix B.2 only identifies the LAST cohort under staggered timing); fail-closed forces the user to either supply `first_treat` (which activates auto-filter to last-cohort + never-treated per Appendix B.2) or redirect to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Lock in `tests/test_methodology_had.py::TestHADDeviations`. -- **Note:** Extensive-margin / positive-untreated-mass fit-time warning (library convention). The paper (de Chaisemartin et al. 2026, Section 2 / Assumption 3) defines HAD for the case where no genuine untreated group exists and recommends (Section 4 practitioner checklist) that a user with a positive mass of untreated units consider a standard DiD instead — but it prescribes only "warn" with NO numeric cutoff, and explicitly RETAINS small untreated shares (the Garrett et al. bonus-depreciation application keeps 12 untreated counties out of 2,954 ≈ 0.4%, with simulations showing close-to-nominal coverage even at `f_{D_2}(0) = 0`). The library therefore emits a `UserWarning` at `HeterogeneousAdoptionDiD.fit()` time only when the fraction of units with EXACTLY-zero post-period dose is `>= 0.10` (`_HAD_EXTENSIVE_MARGIN_ZERO_DOSE_FRAC` in `diff_diff/had.py`) — a 10% library-convention cutoff chosen to sit ~25× above the paper's kept 0.4% example, so valid small-share fits are not nagged while a substantial untreated mass is flagged. **Overall path only:** the warning is emitted after the `aggregate="event_study"` dispatch returns, because the event-study path REQUIRES never-treated (zero-dose) units per Appendix B.2 (the last-cohort filter retains them), so an untreated mass is expected there, not a misuse signal. Surfaces the recommendation at fit time rather than only via `qug_test()`'s zero-dose `UserWarning` (which fires only when the user runs the pretests). Lock in `tests/test_methodology_had.py::TestHADDeviations::test_extensive_margin_warning_is_10pct_library_convention`. +- **Note:** Extensive-margin / positive-untreated-mass fit-time warning (library convention). The paper (de Chaisemartin et al. 2026, Section 2 / Assumption 3) defines HAD for the case where no genuine untreated group exists and recommends (Section 4 practitioner checklist) that a user with a positive mass of untreated units consider a standard DiD instead — but it prescribes only "warn" with NO numeric cutoff, and explicitly RETAINS small untreated shares (the Garrett et al. bonus-depreciation application keeps 12 untreated counties out of 2,954 ≈ 0.4%, with simulations showing close-to-nominal coverage even at `f_{D_2}(0) = 0`). The library therefore emits a `UserWarning` at `HeterogeneousAdoptionDiD.fit()` time only when the fraction of units with EXACTLY-zero post-period dose is `>= 0.10` (`_HAD_EXTENSIVE_MARGIN_ZERO_DOSE_FRAC` in `diff_diff/had.py`) — a 10% library-convention cutoff chosen to sit ~25× above the paper's kept 0.4% example, so valid small-share fits are not nagged while a substantial untreated mass is flagged. **Overall path only:** the warning is emitted after the multi-period event-study dispatch returns, because the event-study path REQUIRES never-treated (zero-dose) units per Appendix B.2 (the last-cohort filter retains them), so an untreated mass is expected there, not a misuse signal. Surfaces the recommendation at fit time rather than only via `qug_test()`'s zero-dose `UserWarning` (which fires only when the user runs the pretests). Lock in `tests/test_methodology_had.py::TestHADDeviations::test_extensive_margin_warning_is_10pct_library_convention`. - **Note:** `covariates=` is reserved but NOT implemented. `HeterogeneousAdoptionDiD.fit(covariates=...)` raises `NotImplementedError` — an explicit keyword-only param, so the message points to the deferred extension instead of letting an unknown kwarg surface as a bare `TypeError`. Covariate-adjusted HAD identification is the paper's Appendix B.1 / Theorem 6 multivariate-covariate extension (a multivariate nonparametric regression of ΔY on (D, X) at the dose boundary), which is not derived in the library. Workaround: pre-residualize the outcome on the covariates before calling `fit()`, or omit `covariates=` for the unconditional WAS estimand. Lock in `tests/test_methodology_had.py::TestHADDeviations::test_covariates_not_implemented_is_documented`. **Requirements checklist (tracks implementation phase completion):** @@ -5045,22 +5045,22 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - [x] Phase 1c: Bias-corrected CI (Equation 8) with `nprobust` parity. Public wrapper `diff_diff.bias_corrected_local_linear` returns `BiasCorrectedFit` with μ̂-scale point estimate, robust SE, and bias-corrected 95% CI `[tau.bc ± z_{1-α/2} * se.rb]`. The β-scale rescaling from Equation 8, `(1/G) Σ D_{g,2}`, is applied by Phase 2's `HeterogeneousAdoptionDiD.fit()`. Parity against `nprobust::lprobust(..., bwselect="mse-dpi")` is asserted at `atol=1e-12` on `tau_cl`/`tau_bc`/`se_cl`/`se_rb`/`ci_low`/`ci_high` across the three unclustered golden DGPs (DGP 1 and DGP 3 typically land closer to `1e-13`). The Python wrapper computes its own `z_{1-α/2}` via `scipy.stats.norm.ppf` inside `safe_inference()`; R's `qnorm` value is stored in the golden JSON for audit, and the parity harness compares Python's CI bounds to R's pre-computed CI bounds so any residual drift is purely the floating-point arithmetic in `tau.bc ± z * se.rb`, not a critical-value disagreement. The clustered DGP achieves bit-parity (`atol=1e-14`) when cluster IDs are in first-appearance order; otherwise BLAS reduction ordering can drift to `atol=1e-10`. Generator: `benchmarks/R/generate_nprobust_lprobust_golden.R`. **Note:** The wrapper matches nprobust's `rho=1` default (`b = h` in auto mode), so Phase 1b's separately-computed `b_mse` is surfaced via `bandwidth_diagnostics.b_mse` but not applied. **Note (public-API surface restriction):** Phase 1c restricts the public wrapper's `vce` parameter to `"nn"`; hc0/hc1/hc2/hc3 raise `NotImplementedError` and are queued for Phase 2+ pending dedicated R parity goldens. The port-level `diff_diff._nprobust_port.lprobust` still accepts all five vce modes (matching R's `nprobust::lprobust` signature) for callers who need the broader surface and accept that the hc-mode variance path — which reuses p-fit hat-matrix leverage for the q-fit residual in R (lprobust.R:229-241) — has not been separately parity-tested. **Note (Phase 1c internal bug workaround):** The clustered golden DGP 4 uses manual `h=b=0.3` to sidestep an nprobust-internal singleton-cluster shape bug in `lprobust.vce` fired by the mse-dpi pilot fits; the Python port has no equivalent bug. - [x] Phase 2a: `HeterogeneousAdoptionDiD` class with separate code paths for Design 1' (`continuous_at_zero`), Design 1 continuous-near-`d̲` (`continuous_near_d_lower`), and Design 1 mass-point. Continuous paths compose Phase 1c's `bias_corrected_local_linear` and form the beta-scale WAS estimate `β̂ = (mean(ΔY) - τ̂_bc) / den` where `τ̂_bc` is the bias-corrected local-linear estimate of the boundary limit `lim_{d↓d̲} E[ΔY | D_2 ≤ d]` and `den = E[D_2]` for Design 1' (paper Theorem 1 / Equation 3 identification; Equation 7 sample estimator) or `den = E[D_2 - d̲]` for Design 1 (paper Theorem 3 / Equation 11, `WAS_{d̲}` under Assumption 6). Mass-point path uses a sample-average 2SLS estimator with instrument `1{D_{g,2} > d̲}` (paper Section 3.2.4). - [x] Phase 2a: `design="auto"` detection rule (`min_g D_{g,2} < 0.01 · median_g D_{g,2}` → continuous_at_zero; modal-min fraction > 2% → mass_point; else continuous_near_lower). Implemented as strict first-match in `diff_diff.had._detect_design`; when `d.min() == 0` exactly, resolves `continuous_at_zero` unconditionally (modal-min check runs only when `d.min() > 0`). Edge case covered: 3% at `D=0` + 97% `Uniform(0.5, 1)` resolves to `continuous_at_zero`, matching the paper-endorsed Design 1' handling of small-share-of-treated samples. -- [x] Phase 2a: Panel validator (`diff_diff.had._validate_had_panel`) verifies `D_{g,1} = 0` for all units, rejects negative post-period doses (`D_{g,2} < 0`) front-door on the original (unshifted) scale, rejects `>2` time periods on the `aggregate="overall"` path (multi-period panels must use `aggregate="event_study"`, Phase 2b), and rejects unbalanced panels and NaN in outcome/dose/unit columns. Both Design 1 paths (`continuous_near_d_lower` and `mass_point`) additionally require `d_lower == float(d.min())` within float tolerance; mismatched overrides raise with a pointer to the unsupported (LATE-like / off-support) estimand. +- [x] Phase 2a: Panel validator (`diff_diff.had._validate_had_panel`) verifies `D_{g,1} = 0` for all units, rejects negative post-period doses (`D_{g,2} < 0`) front-door on the original (unshifted) scale, rejects `>2` time periods on the two-period (overall) path (multi-period panels run the event-study mode, Phase 2b), and rejects unbalanced panels and NaN in outcome/dose/unit columns. Both Design 1 paths (`continuous_near_d_lower` and `mass_point`) additionally require `d_lower == float(d.min())` within float tolerance; mismatched overrides raise with a pointer to the unsupported (LATE-like / off-support) estimand. - [x] Phase 2a: NaN-propagation tests covering constant-y, degenerate-mass-point, and single-cluster-CR1 inputs. The guaranteed NaN coupling is on the DOWNSTREAM triple (`t_stat`, `p_value`, `conf_int`) via the `safe_inference()` gate, which returns NaN on all three whenever `se` is non-finite, zero, or negative. `att` and `se` themselves are raw estimator outputs: on constant-y / no-dose-variation / divide-by-zero the fit paths return `(att=nan, se=nan)` so all five fields move to NaN together; on the degenerate single-cluster CR1 configuration on the mass-point path, `_fit_mass_point_2sls` returns `(att=beta_hat, se=nan)` - `att` is finite (Wald-IV is well defined) while `se` is NaN, so the downstream triple is NaN while `att` remains the raw 2SLS coefficient. The `assert_nan_inference` fixture in `tests/conftest.py` checks the downstream triple against this contract without requiring `att` to be NaN. - **Note (mass-point SE):** Standard errors on the mass-point path use the structural-residual 2SLS sandwich `[Z'X]^{-1} · Ω · [Z'X]^{-T}` with `Ω` built from the structural residuals `u = ΔY - α̂ - β̂·D` (not the reduced-form residuals from an OLS-on-indicator shortcut). Supported: `classical`, `hc1`, and CR1 (cluster-robust) when `cluster=` is supplied. `hc2` and `hc2_bm` raise `NotImplementedError` pending a 2SLS-specific leverage derivation (the OLS leverage `x_i' (X'X)^{-1} x_i` is wrong for 2SLS; the correct finite-sample correction depends on `(Z'X)^{-1}` rather than `(X'X)^{-1}`) plus a dedicated R parity anchor. Queued for the follow-up PR. - **Note (continuous cluster-robust SE, Phase 2a):** On the continuous designs (`continuous_at_zero` / `continuous_near_d_lower`), `cluster=` threads the per-unit cluster IDs into `bias_corrected_local_linear`, whose CCT-2014 robust variance then becomes the cluster-robust nonparametric SE; the β̂-scale SE is `se_robust / |den|`. Because the β-scale rescale is a deterministic linear transform, the estimator-level clustered SE equals the direct `bias_corrected_local_linear(cluster=...).se_robust / |den|` to machine precision — this is the in-library validation anchor, and the clustered CCT SE is itself golden-tested against `nprobust` on DGP 4 (see the Phase 1c note above: `atol=1e-14` in first-appearance cluster order). Cluster IDs must be unit-constant (validated up front; a nonexistent column, NaN, or within-unit-varying cluster now raises rather than being silently ignored, mirroring the mass-point path). Cluster-robust inference is **unidentified with fewer than two clusters in the active kernel window** (`eC = cluster[ind]`, the in-bandwidth subset the CCT variance is actually computed on — a stricter condition than the global cluster count, since clusters can be separated from the boundary by the bandwidth): the guard lives in `_nprobust_port.lprobust` and NaNs `se_rb`/`se_cl`, so `bias_corrected_local_linear.se_robust` is NaN and HAD's `safe_inference` NaNs the t-stat / p-value / CI while the point estimate stays finite — the same single-cluster NaN contract as the mass-point CR1 path (`_fit_mass_point_2sls`), applied at the variance-computation site so it also covers the direct `bias_corrected_local_linear` API. A window with ≥2 distinct clusters is bit-identical (DGP-4 golden parity preserved). A bare `cluster=` gives unweighted cluster-robust inference; the `cluster=` + `survey_design=` composition raises `NotImplementedError`: the Binder (1983) TSL survey variance is composed from the per-unit influence function via `compute_survey_if_variance` and would silently override the cluster-robust SE — for weighted clustering route through `survey_design=SurveyDesign(weights='', psu='')` instead. Result metadata reports `vcov_type="cr1"` + `cluster_name=` with `inference_method="analytical_nonparametric"` (distinguishing the clustered CCT variance from the mass-point 2SLS CR1 sandwich). Estimator-level cluster threading also extends to the **Phase 2b event-study** path (cluster-robust per-horizon pointwise CIs on both designs plus a cluster-robust simultaneous sup-t band; the per-horizon variance-family reconciliation is documented in "Note (HAD clustered event-study sup-t band)" above) — the former "cluster ignored" `UserWarning` is removed. - **Note (Design 1 identification):** `continuous_near_d_lower` and `mass_point` fits emit a `UserWarning` surfacing that `WAS_{d̲}` identification requires Assumption 6 (or Assumption 5 for sign identification only) beyond parallel trends, and that neither is testable via pre-trends. `continuous_at_zero` (Design 1', Assumption 3 only) does not emit this warning. - **Note (CI endpoints):** Because the continuous-path `att` is `(mean(ΔY) - τ̂_bc) / den`, the beta-scale CI endpoints reverse relative to the Phase 1c boundary-limit CI: `CI_lower(β̂) = (mean(ΔY) - CI_upper(τ̂_bc)) / den` and `CI_upper(β̂) = (mean(ΔY) - CI_lower(τ̂_bc)) / den`. The `HeterogeneousAdoptionDiD.fit()` implementation computes `att ± z · se` directly via `safe_inference`, which handles the reversal naturally from the transformed point estimate. - - **Note (Phase 2a/2b scope, superseded by Phase 4.5):** Phase 2a ships the single-period `aggregate="overall"` path; Phase 2b lifts `aggregate="event_study"` (Appendix B.2 multi-period extension) which returns a `HeterogeneousAdoptionDiDEventStudyResults` with per-event-time WAS estimates and pointwise CIs. The original Phase 2a/2b release raised `NotImplementedError` on all weighting; Phase 4.5 (A/B/C0) lifted that with the per-design vcov contract documented above (see L2340-L2379, including the mass-point `vcov_type="classical"` deviation and `cband=True` sup-t restriction); the `survey_design=` path composes Binder (1983) TSL via `compute_survey_if_variance` on both continuous and mass-point IFs. + - **Note (Phase 2a/2b scope, superseded by Phase 4.5):** Phase 2a ships the single-period (two-period panel) overall path; Phase 2b lifts the multi-period event-study mode (Appendix B.2 multi-period extension) which returns a `HeterogeneousAdoptionDiDEventStudyResults` with per-event-time WAS estimates and pointwise CIs. The original Phase 2a/2b release raised `NotImplementedError` on all weighting; Phase 4.5 (A/B/C0) lifted that with the per-design vcov contract documented above (see L2340-L2379, including the mass-point `vcov_type="classical"` deviation and `cband=True` sup-t restriction); the `survey_design=` path composes Binder (1983) TSL via `compute_survey_if_variance` on both continuous and mass-point IFs. - **Note (panel-only):** The paper (Section 2) defines HAD on *panel or repeated cross-section* data, but both the overall and event-study paths ship a panel-only implementation: `HeterogeneousAdoptionDiD.fit()` requires a balanced panel with a unit identifier so that unit-level first differences `ΔY_{g,t} = Y_{g,t} - Y_{g,t_anchor}` can be formed. Repeated-cross-section inputs (disjoint unit IDs between periods) are rejected by the balanced-panel validator. RCS support is queued for a follow-up PR (tracked in `DEFERRED.md`); it will need a separate identification path based on pre/post cell means rather than unit-level differences. -- [x] Phase 2b: Multi-period event-study extension (Appendix B.2). `aggregate="event_study"` produces per-event-time WAS estimates using a uniform `F-1` baseline (`ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every horizon), reusing the three Phase 2a design paths on per-horizon first differences. Pre-period placebos included for `e <= -2` (the anchor `e = -1` is skipped since `ΔY = 0` trivially). Post-period estimates for `e >= 0`. The joint Stute test (Equation 18) across pre-periods is a SEPARATE diagnostic deferred to a **Phase 3 follow-up patch** (initially deferred; shipped in Phase 3 via `stute_joint_pretest()` — see the Phase 3 entries below). +- [x] Phase 2b: Multi-period event-study extension (Appendix B.2). A multi-period panel produces per-event-time WAS estimates using a uniform `F-1` baseline (`ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every horizon), reusing the three Phase 2a design paths on per-horizon first differences. Pre-period placebos included for `e <= -2` (the anchor `e = -1` is skipped since `ΔY = 0` trivially). Post-period estimates for `e >= 0`. The joint Stute test (Equation 18) across pre-periods is a SEPARATE diagnostic deferred to a **Phase 3 follow-up patch** (initially deferred; shipped in Phase 3 via `stute_joint_pretest()` — see the Phase 3 entries below). - [x] Phase 2b: event-study `cluster=` threading — cluster-robust per-horizon pointwise CIs (both designs) AND a cluster-robust simultaneous band via the clustered branch of `_sup_t_multiplier_bootstrap` (continuous scale 1.0; mass-point `√(G/(G-1))`). Closes the former "cluster ignored on the nonparametric path" deferral (`TODO.md`). See "Note (HAD clustered event-study sup-t band)". `cluster=` + `survey_design=` remains rejected (for weighted clustering route through `survey_design=SurveyDesign(weights=..., psu=...)`). - **Note (Phase 2b last-cohort filter):** When `first_treat` indicates more than one nonzero cohort, the panel is auto-filtered to the last-treatment cohort (`F_last = max(cohorts)`) **plus never-treated units** (`first_treat = 0`), with a `UserWarning` naming kept/dropped unit counts and dropped cohort labels. Paper Appendix B.2 is explicit that HAD "may be used only for the LAST treatment cohort in a staggered design"; the auto-filter implements this prescription, retaining never-treated units per the paper's "there must be an untreated group, at least till the period where the last cohort gets treated" requirement. Only earlier-cohort units (with `first_treat > 0` and `< F_last`) are dropped — never-treated units satisfy the dose invariant at every period (`D = 0` throughout) and preserve Design 1' identifiability (boundary at `0`) when last-cohort doses are uniformly positive. When `first_treat` is omitted on a >2-period panel, the validator infers each unit's first-positive-dose period from the dose path; if multiple distinct first-positive-dose cohorts are detected, the estimator raises a front-door `ValueError` directing users to pass `first_treat` (which activates the auto-filter) or use `ChaisemartinDHaultfoeuille` for full staggered support — there is no silent acceptance of staggered panels without cohort metadata. Common-adoption panels (single first-positive-dose cohort, or only never-treated + one cohort) pass through unchanged with `F` inferred from the dose invariant, and require dose contiguity (pre-periods < post-periods in natural ordering). Non-contiguous dose sequences (e.g., reverse treatment) raise with a pointer to `ChaisemartinDHaultfoeuille`. - **Note (Phase 2b constant-dose requirement):** The event-study aggregation uses `D_{g, F}` (first-treatment-period dose) as the single regressor for every event-time horizon, per paper Appendix B.2's "once treated, stay treated with the same dose" convention. The validator REJECTS panels where a unit has time-varying dose across post-treatment periods (`D_{g, t} != D_{g, F}` for any `t >= F` within-unit, beyond float tolerance) with a front-door `ValueError`, directing users with genuinely time-varying post-treatment doses to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Silent acceptance would misattribute later-horizon treatment-effect heterogeneity to the period-F dose. A follow-up PR could implement a time-varying-dose estimator; tracked in `DEFERRED.md`. - **Note (Phase 2b per-horizon SE):** Each event-time horizon uses an INDEPENDENT sandwich computed on that horizon's first differences: continuous paths use the CCT-2014 robust SE from Phase 1c divided by `|den|`; mass-point path uses the structural-residual 2SLS sandwich from Phase 2a. This produces pointwise CIs per horizon, matching the paper's Pierce-Schott application (Section 5.2, Figure 2: "nonparametric pointwise CIs"). Joint cross-horizon covariance (IF-based stacking or block bootstrap) is NOT computed — the paper does not derive it and all reported CIs are pointwise. Follow-up PRs may add joint covariance for cross-horizon hypothesis tests; current tracking in `DEFERRED.md`. - **Note (Phase 2b baseline convention):** All event-time horizons use a uniform `F-1` anchor: `ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every `t`. This is consistent with the paper's Garrett-et-al. application (Section 5.1: "outcome `Y_{g,t} - Y_{g,2001}`" where `F = 2002`), simplifies event-time indexing (`e = t - F` so `e = -1` is the anchor, skipped), and keeps the implementation symmetric for pre- and post-period horizons. The paper review text's asymmetric "`Y_{g,t} - Y_{g,1}` for pre" / "`Y_{g,t} - Y_{g,F-1}` for post" phrasing is covered by the uniform convention since both give the same placebo interpretation under parallel trends (the paper's own applications use the uniform anchor). - - **Note (Phase 2b result class):** `aggregate="event_study"` returns a new `HeterogeneousAdoptionDiDEventStudyResults` dataclass (distinct from the single-period `HeterogeneousAdoptionDiDResults`) with per-horizon arrays (`event_times`, `att`, `se`, `t_stat`, `p_value`, `conf_int_low`, `conf_int_high`, `n_obs_per_horizon`) and shared metadata. `to_dataframe()` returns a tidy per-horizon DataFrame; `to_dict()` returns a dict with list-of-per-horizon fields. The static return-type annotation on `fit()` is `Union[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]`, matching the runtime polymorphism on `aggregate`; callers should narrow via `isinstance` (or via the aggregate they passed) when reading aggregate-specific fields. - - **Note (Phase 3 pretest workflow):** Phase 3 ships the Section 4 pre-test diagnostics in a new module `diff_diff/had_pretests.py` (separate from the 2,800-line `had.py` estimator module). The three tests — `qug_test`, `stute_test`, `yatchew_hr_test` — accept raw `(d, dy)` arrays and return their own result dataclasses (`QUGTestResults`, `StuteTestResults`, `YatchewTestResults`); the composite `did_had_pretest_workflow` dispatches on `aggregate` (`"overall"` requires a balanced two-period panel; `"event_study"` accepts a multi-period panel with >= 3 periods) and returns `HADPretestReport` with a priority-ordered verdict string. The `data`-only workflow signature (no `result=`-consuming variant) avoids coupling pretests to the `BiasCorrectedFit` internal state, which does not expose residuals. Below-minimum sample sizes emit `UserWarning` and return all-NaN inference (no `ValueError` raise) for library consistency with the `safe_inference` convention; input-shape violations (non-1D, NaN-containing, mismatched length) still raise. No multiple-testing adjustment (Bonferroni/Holm) is applied — the paper does not prescribe one. **Partial-workflow semantic (path-dependent):** `aggregate="overall"` (default; two-period panel) runs paper steps 1 (QUG) + 3 (linearity via Stute + Yatchew-HR) ONLY; step 2 (Assumption 7 pre-trends test via Equation 18) is NOT covered on this path and a fail-to-reject verdict appends an explicit Assumption 7 gap flag (`"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"`). `aggregate="event_study"` (multi-period panel, requires >=3 periods + earlier pre-period for joint pretrends) closes step 2 via joint Stute pre-trends (Equation 18) over pre-period horizons AND joint Stute homogeneity over post-period horizons; the verdict on a fail-to-reject across QUG + joint pretrends + joint homogeneity reads "TWFE admissible under Section 4 assumptions" without the Assumption 7 caveat. The `HADPretestReport` docstring and structural fields (`pretrends_joint`, `homogeneity_joint`) reflect the aggregate-dependent coverage. + - **Note (Phase 2b result class):** a multi-period panel returns a new `HeterogeneousAdoptionDiDEventStudyResults` dataclass (distinct from the single-period `HeterogeneousAdoptionDiDResults`) with per-horizon arrays (`event_times`, `att`, `se`, `t_stat`, `p_value`, `conf_int_low`, `conf_int_high`, `n_obs_per_horizon`) and shared metadata. `to_dataframe()` returns a tidy per-horizon DataFrame; `to_dict()` returns a dict with list-of-per-horizon fields. The static return-type annotation on `fit()` is `Union[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]`, matching the runtime polymorphism on the panel shape; callers should narrow via `isinstance` (or via the panel shape they supplied) when reading aggregate-specific fields. + - **Note (Phase 3 pretest workflow):** Phase 3 ships the Section 4 pre-test diagnostics in a new module `diff_diff/had_pretests.py` (separate from the 2,800-line `had.py` estimator module). The three tests — `qug_test`, `stute_test`, `yatchew_hr_test` — accept raw `(d, dy)` arrays and return their own result dataclasses (`QUGTestResults`, `StuteTestResults`, `YatchewTestResults`); the composite `did_had_pretest_workflow` dispatches on the panel shape (a balanced two-period panel runs the overall battery; a multi-period panel with >= 3 periods runs the event-study battery) and returns `HADPretestReport` with a priority-ordered verdict string. The `data`-only workflow signature (no `result=`-consuming variant) avoids coupling pretests to the `BiasCorrectedFit` internal state, which does not expose residuals. Below-minimum sample sizes emit `UserWarning` and return all-NaN inference (no `ValueError` raise) for library consistency with the `safe_inference` convention; input-shape violations (non-1D, NaN-containing, mismatched length) still raise. No multiple-testing adjustment (Bonferroni/Holm) is applied — the paper does not prescribe one. **Partial-workflow semantic (path-dependent):** a two-period panel (the overall battery) runs paper steps 1 (QUG) + 3 (linearity via Stute + Yatchew-HR) ONLY; step 2 (Assumption 7 pre-trends test via Equation 18) is NOT covered on this path and a fail-to-reject verdict appends an explicit Assumption 7 gap flag (`"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"`). a multi-period panel (the event-study battery; requires >=3 periods + earlier pre-period for joint pretrends) closes step 2 via joint Stute pre-trends (Equation 18) over pre-period horizons AND joint Stute homogeneity over post-period horizons; the verdict on a fail-to-reject across QUG + joint pretrends + joint homogeneity reads "TWFE admissible under Section 4 assumptions" without the Assumption 7 caveat. The `HADPretestReport` docstring and structural fields (`pretrends_joint`, `homogeneity_joint`) reflect the aggregate-dependent coverage. - **Note (Phase 3 Stute bootstrap):** The Stute CvM statistic is implemented via the simplified-form `S = (1/G²) · Σ cumsum²`, which is algebraically equivalent to the paper's stated `Σ(g/G)² · ((1/g) Σ eps_{(h)})²` form. Bootstrap follows the paper's Appendix D Algorithm literally: each iteration refits OLS on `dy_b = a_hat + b_hat · d + eps · eta` (null DGP multiplied by Mammen weights). The ~2× performance follow-up landed 2026-07-07 as **loop-invariant hoisting**, not the once-sketched `M = I - X(X'X)⁻¹X'` materialization (O(G²) memory — would not scale): Mammen draws are batched through memory-bounded `rng.choice` calls whose variate stream is identical to per-iteration draws, and the d-moments + CvM tie-block indices are precomputed once per test — each replicate still runs the paper-literal O(G) refit on the same values, so `bootstrap_S`/`cvm_stat`/`p_value` are bit-identical to the per-iteration form (frozen byte-copy parity test at `tests/test_had_pretests.py::TestStuteBootstrapHoistedInvariants`). Bootstrap reproducibility uses `np.random.default_rng(seed)` (library convention, matching `wild_bootstrap_se`); `seed=42` in tests for bitwise stability. - **Note (Phase 3 Yatchew normalizer):** `σ̂²_diff` in `yatchew_hr_test` divides by `2G` (paper-literal from Theorem 7), NOT by `2(G-1)`. A unit test hand-computes `σ̂²_diff` at `G=4` on deterministic inputs and asserts the `2G`-normalizer form at `atol=1e-12` so any later regression to the (asymptotically equivalent but finite-sample different) `2(G-1)` form would fail the test. `σ̂⁴_W` uses `np.mean(eps_{(g)}² · eps_{(g-1)}²)` which divides by `G-1` via `np.mean` length, matching the paper's Theorem 7 / Equation 29 normalization. - **Note (Phase 3 Yatchew tie policy):** `yatchew_hr_test` REJECTS duplicate dose values with a `UserWarning` + all-NaN result at the front door. The difference-based variance estimator `σ̂²_diff` and the heteroskedasticity-robust scale `σ̂⁴_W` both use adjacent differences (of sorted dy and of adjacent squared residuals, respectively); under tied doses the within-tie row ordering is arbitrary (stable sort falls back to input order), so the statistic becomes non-methodological and order-dependent. Callers with tied doses (mass-point designs, discretised dose registers) should use `stute_test` instead — its tie-safe Cramer-von Mises statistic collapses tie blocks to the post-tie cumulative sum and is provably order-invariant under within-tie permutations. A regression test on `stute_test` asserts bit-identical `cvm_stat` across tied-dose permutations at `atol=1e-14`; a matching regression on `yatchew_hr_test` asserts the NaN+warning behavior on duplicated and constant doses. This tie policy is a Phase 3 diff-diff choice (the paper describes Yatchew only as "sort by D" and does not specify a tie rule); an alternative implementation could document a justified tie-resolution convention and back it with permutation tests, but at the cost of a methodology surface the paper does not cover. **Workflow fallback:** `did_had_pretest_workflow` follows the paper's "Stute OR Yatchew" step-3 wording — a conclusive Stute (which is tie-safe) is sufficient to adjudicate linearity even when Yatchew returns NaN on tied-dose panels. The composite verdict then reads "QUG and linearity diagnostics fail-to-reject (Yatchew NaN - skipped); ..." rather than forcing the whole report to "inconclusive", and `all_pass` is `True` when QUG + at least one conclusive linearity test fail-to-reject. @@ -5068,15 +5068,15 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - [x] Phase 3: `qug_test()` (`T = D_{2,(1)} / (D_{2,(2)} - D_{2,(1)})`, rejection `{T > 1/α - 1}`). One-sided asymptotic p-value `1 / (1 + T)` under the Exp(1)/Exp(1) limit law (Theorem 4). Zero-dose observations filtered upfront (with `UserWarning`); `D_{(1)} == D_{(2)}` tie returns all-NaN inference (conservative). Closed-form tight parity tested at `atol=1e-12`. - [x] Phase 3: `stute_test()` Cramér-von Mises with Mammen wild bootstrap. Statistic `S = (1/G^2) Σ (cumsum_g)^2` (algebraically equivalent to paper's `Σ(g/G)^2 · ((1/g) Σ eps_{(h)})^2`). Bootstrap follows paper Appendix D Algorithm literal (per-iteration OLS refit). `n_bootstrap=999` default, `n_bootstrap >= 99` validated. `G < 10` returns NaN; `G > 100_000` emits a `UserWarning` pointing to `yatchew_hr_test`. The ~2× performance follow-up landed 2026-07-07 (loop-invariant hoisting, bit-identical — see the Phase 3 Stute bootstrap Note above). - [x] Phase 3: `yatchew_hr_test()` heteroskedasticity-robust specification test. Test statistic `T_hr = sqrt(G) · (σ̂²_lin - σ̂²_diff) / σ̂²_W` from paper Equation 29. Normalizer `σ̂²_diff` divides by `2G` (paper-literal Theorem 7), NOT `2(G-1)`; hand-computed tight parity asserted at `atol=1e-12`. One-sided standard-normal critical value. `G < 3` returns NaN. Phase 3 shipped only the linearity null (paper Theorem 7); the `null="mean_independence"` R-parity extension shipped post-PR #392 (see the algorithm-variant block above for the contract). -- [x] Phase 3: `did_had_pretest_workflow()` composite helper. `data`-only entry point with `aggregate` dispatch: `aggregate="overall"` (default) requires a balanced two-period panel — multi-period panels are rejected at the front door by `_validate_had_panel` with a pointer to `aggregate="event_study"` — and runs steps 1 (QUG) + 3 (Stute + Yatchew-HR) only; `aggregate="event_study"` takes a multi-period panel (>=3 periods) and additionally runs step 2 (joint Stute pre-trends over pre-period horizons) + joint Stute homogeneity over post-period horizons, populating `pretrends_joint` / `homogeneity_joint`. `seed` forwards to all bootstrap-based tests (QUG and Yatchew are deterministic). Returns `HADPretestReport` with priority-ordered verdict string. On `aggregate="overall"` a fail-to-reject verdict explicitly flags the Assumption 7 gap rather than claiming unconditional TWFE safety: `"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"`; on `aggregate="event_study"` a fail-to-reject across all three covered diagnostics reads `"TWFE admissible under Section 4 assumptions"` without the Assumption 7 caveat. Verdict priority follows the paper's one-way rule (TWFE admissible only if NO test rejects): **conclusive rejections are the primary verdict and are NEVER hidden by inconclusive status** — any unresolved-step note is appended via `"; additional steps unresolved: ..."` rather than replacing the rejection. The pure `"inconclusive - QUG NaN"` / `"inconclusive - both Stute and Yatchew linearity tests NaN"` forms only fire when NO conclusive test rejects AND a required step is unresolved. The partial-workflow fail-to-reject verdict may carry a `"(Yatchew NaN - skipped)"` (or Stute) suffix when one linearity test is NaN but the other is conclusive (step 3 resolved via the paper's "Stute OR Yatchew" wording). Bundled rejection-reason strings name each failed assumption in the conclusive-rejection case. `all_pass` is `True` iff QUG is conclusive AND at least one of Stute/Yatchew is conclusive AND no conclusive test rejects. **Non-negative-dose contract**: all three raw linearity helpers (`qug_test`, `stute_test`, `yatchew_hr_test`) raise a front-door `ValueError` on any `d < 0`, mirroring the `_validate_had_panel` guard (paper Section 2 HAD support restriction). On the `aggregate="overall"` path, the panel must already be exactly two periods (`_validate_had_panel` raises with a pointer to `aggregate="event_study"` otherwise); the first-difference helper computes `(t_post, t_pre)` per unit and feeds each raw helper directly. On the `aggregate="event_study"` path, joint Stute is dispatched across pre-period and post-period horizons directly (the joint Equation-18 form, no per-horizon pre-slicing). +- [x] Phase 3: `did_had_pretest_workflow()` composite helper. `data`-only entry point with panel-shape dispatch: a balanced two-period panel runs the overall battery - steps 1 (QUG) + 3 (Stute + Yatchew-HR) only; a multi-period panel (>=3 periods) runs the event-study battery and additionally runs step 2 (joint Stute pre-trends over pre-period horizons) + joint Stute homogeneity over post-period horizons, populating `pretrends_joint` / `homogeneity_joint`. `seed` forwards to all bootstrap-based tests (QUG and Yatchew are deterministic). Returns `HADPretestReport` with priority-ordered verdict string. On the two-period (overall) path a fail-to-reject verdict explicitly flags the Assumption 7 gap rather than claiming unconditional TWFE safety: `"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"`; on the multi-period (event-study) path a fail-to-reject across all three covered diagnostics reads `"TWFE admissible under Section 4 assumptions"` without the Assumption 7 caveat. Verdict priority follows the paper's one-way rule (TWFE admissible only if NO test rejects): **conclusive rejections are the primary verdict and are NEVER hidden by inconclusive status** — any unresolved-step note is appended via `"; additional steps unresolved: ..."` rather than replacing the rejection. The pure `"inconclusive - QUG NaN"` / `"inconclusive - both Stute and Yatchew linearity tests NaN"` forms only fire when NO conclusive test rejects AND a required step is unresolved. The partial-workflow fail-to-reject verdict may carry a `"(Yatchew NaN - skipped)"` (or Stute) suffix when one linearity test is NaN but the other is conclusive (step 3 resolved via the paper's "Stute OR Yatchew" wording). Bundled rejection-reason strings name each failed assumption in the conclusive-rejection case. `all_pass` is `True` iff QUG is conclusive AND at least one of Stute/Yatchew is conclusive AND no conclusive test rejects. **Non-negative-dose contract**: all three raw linearity helpers (`qug_test`, `stute_test`, `yatchew_hr_test`) raise a front-door `ValueError` on any `d < 0`, mirroring the `_validate_had_panel` guard (paper Section 2 HAD support restriction). On the two-period (overall) path the panel must be exactly two periods (`_validate_had_panel` raises, in panel-shape vocabulary, otherwise); the first-difference helper computes `(t_post, t_pre)` per unit and feeds each raw helper directly. On the multi-period (event-study) path, joint Stute is dispatched across pre-period and post-period horizons directly (the joint Equation-18 form, no per-horizon pre-slicing). - [x] Phase 4: Pierce-Schott (2016) replication harness reproduces Figure 2 values. **Waived 2026-05-20:** see Deviations block above; the paper itself self-acknowledges that NP estimators are too noisy to be informative on the LBD-restricted PNTR panel (Section 5.2), and R parity at `atol=1e-8` via `tests/test_did_had_parity.py` is a strictly stronger correctness anchor than Figure-2 reproduction on a proxy panel. Recorded as waived in the `DEFERRED.md` decision record. - [x] Phase 4: Full DGP 1/2/3 coverage-rate reproduction from Table 1. **Waived 2026-05-20:** see Deviations block above; R parity at `atol=1e-8` on the same 3 DGPs reproduces the exact point estimate and SE algorithm (Python ≡ R ≡ paper) at sample-mean level — stronger than coverage-rate MC, which re-verifies asymptotic-coverage already pinned by R parity. Recorded as waived in the `DEFERRED.md` decision record. - [x] Phase 5 (wave 1, PR #402): `practitioner_next_steps()` integration for HAD results - `_handle_had` and `_handle_had_event_study` route both result classes through HAD-specific Baker et al. (2025) step guidance with bidirectional HAD ↔ ContinuousDiD Step-4 routing closure. The `_check_nan_att` helper extends to ndarray `att` (HAD event-study) via `np.all(np.isnan(arr))` semantics; scalar path bit-exact preserved. The `llms-full.txt` HAD section's documented constructor and `fit()` parameter lists are regression-locked against `inspect.signature(HeterogeneousAdoptionDiD.__init__)` and `HeterogeneousAdoptionDiD.fit` for parameter-name presence (parameter defaults and the non-return parameter type annotations remain unpinned by the current `inspect.signature` test). The `fit()` return annotation is widened to `Union[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]` at the source-code level to match the runtime polymorphism, AND that union is now pinned at the test level by `tests/test_had.py::TestFitReturnAnnotation::test_fit_return_annotation_is_union_of_result_classes` via `typing.get_type_hints` so the contract cannot drift silently. - [x] Phase 5 (wave 1, PR #402): `llms-full.txt` HeterogeneousAdoptionDiD section + result-class blocks + `## HAD Pretests` index + Choosing-an-Estimator row landed; constructor / fit() parameter names are regression-locked against `inspect.signature(HeterogeneousAdoptionDiD.__init__)` and `HeterogeneousAdoptionDiD.fit` for parameter-name presence (parameter defaults and the non-return parameter type annotations remain unpinned; the `fit()` return-type union is locked BOTH at the source-code level AND at the test level by `TestFitReturnAnnotation`); result-class field tables enumerate every public dataclass field (regression-tested via `dataclasses.fields()`); `llms-practitioner.txt` Step 4 decision tree distinguishes ContinuousDiD (per-dose ATT(d), needs never-treated) from HeterogeneousAdoptionDiD (WAS, universal-rollout-compatible). - [x] Phase 5 (partial): README catalog one-liner, bundled `llms.txt` `## Estimators` entry, `docs/api/had.rst` (autoclass for the three classes), and `docs/references.rst` citation landed in PR #372 docs refresh. -- [x] Phase 5 (wave 2 first slice, PR #409): T21 HAD pretest workflow tutorial (`docs/tutorials/21_had_pretest_workflow.ipynb`) — composite pre-test walkthrough for `did_had_pretest_workflow`. Uses a `Uniform[$0.01K, $50K]` dose-distribution variant of T20's brand-campaign panel (true support strictly positive but near-zero, chosen so QUG fails-to-reject `H0: d_lower = 0` in finite sample). Walks through `aggregate="overall"` (Steps 1 + 3 only, verdict explicitly flags Step 2 deferral) and upgrades to `aggregate="event_study"` (joint pre-trends Stute + joint homogeneity Stute close the gap). Side panel exercises both `yatchew_hr_test` null modes (`linearity` vs `mean_independence`). Companion drift-test file `tests/test_t21_had_pretest_workflow_drift.py` (17 tests pinning panel composition, both verdict pivots, structural anchors, deterministic stats, bootstrap p-value tolerance bands per backend, and `HAD(design="auto")` resolution to `continuous_at_zero` on this panel). +- [x] Phase 5 (wave 2 first slice, PR #409): T21 HAD pretest workflow tutorial (`docs/tutorials/21_had_pretest_workflow.ipynb`) — composite pre-test walkthrough for `did_had_pretest_workflow`. Uses a `Uniform[$0.01K, $50K]` dose-distribution variant of T20's brand-campaign panel (true support strictly positive but near-zero, chosen so QUG fails-to-reject `H0: d_lower = 0` in finite sample). Walks through the two-period overall battery (Steps 1 + 3 only, verdict explicitly flags Step 2 deferral) and upgrades to the multi-period event-study battery (joint pre-trends Stute + joint homogeneity Stute close the gap). Side panel exercises both `yatchew_hr_test` null modes (`linearity` vs `mean_independence`). Companion drift-test file `tests/test_t21_had_pretest_workflow_drift.py` (17 tests pinning panel composition, both verdict pivots, structural anchors, deterministic stats, bootstrap p-value tolerance bands per backend, and `HAD(design="auto")` resolution to `continuous_at_zero` on this panel). - [x] Phase 5 (wave 2 second slice): T22 weighted/survey HAD tutorial (`docs/tutorials/22_had_survey_design.ipynb`) - shipped as the follow-up to PR #432. End-to-end walkthrough of `HeterogeneousAdoptionDiD` + `did_had_pretest_workflow` under `SurveyDesign(weights, strata, psu, fpc)` on a BRFSS-shape state-rollout panel (5 strata x 6 PSUs/stratum x 2 states/PSU = 60 states; post-stratification raking weights with CV ~ 0.30; FPC = 30 PSUs/stratum). Companion drift-test file `tests/test_t22_had_survey_design_drift.py` (32 tests pinning panel composition, naive-vs-survey SE inflation direction, design auto-detection, event-study cband-vs-pointwise width ordering, `_QUG_DEFERRED_SUFFIX` substring on `report.verdict` for both overall and event-study paths, the distinct `report.summary()` QUG-skip note on the event-study path, deterministic Yatchew sigma2_*, bootstrap p-value anchored windows of total width 0.30 (± 0.15 around seeded centers) per `feedback_strata_bootstrap_path_divergence`, workflow-surface separation between overall and event-study paths, and the weighted point-estimation contract via the `_fit_continuous` algebraic identity). -- [x] Documentation of non-testability of Assumptions 5 and 6. **Closed 2026-05-20:** `HeterogeneousAdoptionDiD` class docstring carries a "Non-testable assumptions (paper Section 3.1.2)" Notes block; `qug_test` / `stute_test` / `yatchew_hr_test` / `did_had_pretest_workflow` Notes sections carry "Scope (what this test does NOT cover)" clauses explicitly stating they verify ADJACENT identifying conditions (QUG: support-infimum null `d_lower = 0`; Stute / Yatchew: Assumption 8 linearity; `joint_pretrends_test`: Assumption 7 mean-independence) and CANNOT test Assumptions 5 or 6. The composite workflow verdict string does NOT mention Assumptions 5 or 6 — it only flags the Assumption 7 step-2 gap on the two-period `aggregate="overall"` path. The Assumption 5/6 non-testability caveat is surfaced separately by (a) `HAD.fit()`'s fit-time `UserWarning` in `diff_diff/had.py` (search for "---- Assumption 5/6 warning on Design 1 paths ----") which fires whenever the resolved design is Design 1 family (`continuous_near_d_lower` or `mass_point`), and (b) T21 (HAD pretest workflow tutorial) tutorial prose. +- [x] Documentation of non-testability of Assumptions 5 and 6. **Closed 2026-05-20:** `HeterogeneousAdoptionDiD` class docstring carries a "Non-testable assumptions (paper Section 3.1.2)" Notes block; `qug_test` / `stute_test` / `yatchew_hr_test` / `did_had_pretest_workflow` Notes sections carry "Scope (what this test does NOT cover)" clauses explicitly stating they verify ADJACENT identifying conditions (QUG: support-infimum null `d_lower = 0`; Stute / Yatchew: Assumption 8 linearity; `joint_pretrends_test`: Assumption 7 mean-independence) and CANNOT test Assumptions 5 or 6. The composite workflow verdict string does NOT mention Assumptions 5 or 6 — it only flags the Assumption 7 step-2 gap on the two-period (overall) path. The Assumption 5/6 non-testability caveat is surfaced separately by (a) `HAD.fit()`'s fit-time `UserWarning` in `diff_diff/had.py` (search for "---- Assumption 5/6 warning on Design 1 paths ----") which fires whenever the resolved design is Design 1 family (`continuous_near_d_lower` or `mass_point`), and (b) T21 (HAD pretest workflow tutorial) tutorial prose. - [x] Warnings for staggered treatment timing (redirect to `ChaisemartinDHaultfoeuille`). **Closed 2026-05-20:** fail-closed `ValueError` at `diff_diff/had.py:1624` (see Deviations § "Library extension: Staggered-timing fail-closed" for the rationale on raising vs warning). - [x] `NotImplementedError` phase pointer when `covariates=` is passed (Theorem 6 future work). **Closed 2026-06-01:** `HAD.fit()` now takes an explicit keyword-only `covariates=None` param and raises `NotImplementedError` (with the Appendix B.1 / Theorem 6 multivariate-covariate-extension pointer + a pre-residualization workaround) when it is not None, replacing the prior bare `TypeError` from the absent kwarg. See the `- **Note:**` ("`covariates=` is reserved but NOT implemented") above and `diff_diff/had.py::HeterogeneousAdoptionDiD.fit`; locked by `tests/test_methodology_had.py::TestHADDeviations::test_covariates_not_implemented_is_documented`. diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md index 2e0e70673..018fe64bb 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -484,8 +484,9 @@ a library setting. fields. Pretrends POWER: natively `compute_pretrends_power()` supports MPD / CS / SunAbraham fits; since row M-024 a Stacked `results.aggregate('event_study')` - CONTAINER also admits (kappa_pre >= 2). On plain (no fit-time - `aggregate=`) CS fits, `DiagnosticReport` internally derives the CS + CONTAINER also admits (kappa_pre >= 2). On CS fits (whose raw + `event_study_effects` is None on every fit since 4.0), + `DiagnosticReport` internally derives the CS container and feeds it to `compute_pretrends_power` / `HonestDiD.sensitivity_analysis` — CS is M-093-admitted with pinned raw-route parity; the admission set itself is unchanged (widening diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md index d9bade867..74ddbb125 100644 --- a/docs/migration-4.0.md +++ b/docs/migration-4.0.md @@ -131,13 +131,23 @@ it to `post=`; if you pass a calendar column to an event-study or staggered fit, **Dropped parameters are not always deletions.** A parameter with no successor in the appendix may still need a replacement call — see its `Fix` cell. +**Dropped `fit()` parameters shift the positional slots after them.** Removing the fit-time +`aggregate=`/`balance_e=` keywords moves every later positional parameter left: on +`CallawaySantAnna` `survey_design` moves from slot 9 to 7; on `EfficientDiD` `survey_design` +9 -> 7 and `store_eif` 10 -> 8; on `StackedDiD` (`aggregate` sat before them) +`population`/`survey_design`/`covariates` move 7/8/9 -> 6/7/8; on `ChaisemartinDHaultfoeuille` +`L_max`, `covariates`, `trends_linear`, `trends_nonparam`, `honest_did`, `heterogeneity`, +`design2` and `survey_design` each move one slot left (7..14 -> 6..13). `HeterogeneousAdoptionDiD` +and `did_had_pretest_workflow` shift nothing (everything after the removed slot is keyword-only; +a seventh positional on `fit()` now raises). Pass these by keyword. + ## Post-fit aggregation Aggregation moves off `fit()` onto the results object: ```python -# 3.x -results = CallawaySantAnna().fit(data, ..., aggregate="event_study") +# 3.x (a TypeError from 4.0 - the keyword is gone): +# results = CallawaySantAnna().fit(data, ..., aggregate="event_study") # 4.0 results = CallawaySantAnna().fit(data, ...) diff --git a/docs/performance-plan.md b/docs/performance-plan.md index 9cbf202bd..4d885624b 100644 --- a/docs/performance-plan.md +++ b/docs/performance-plan.md @@ -257,7 +257,8 @@ QR — Phase 3 candidate). **R `did` yardstick** (did 2.5.1 — the 2.5.0 performance release with `faster_mode` and the tiled-contraction bootstrap — R 4.5.2 + OpenBLAS 0.3.33; equal work: `att_gt` + -`aggte` simple+dynamic+group vs `fit(aggregate="all")`; identical `biters`/`cband`; +`aggte` simple+dynamic+group vs a plain `fit()` followed by post-fit `aggregate("event_study")` + +`aggregate("group")` (the 3.x `fit(aggregate="all")` route, removed at 4.0 - same work); identical `biters`/`cband`; medians of 3; run configs recorded in each JSON artifact's metadata): | Scenario | diff-diff | R 1-core | R 14-core (`pl=TRUE, cores=14`) | diff --git a/docs/performance-scenarios.md b/docs/performance-scenarios.md index cafeff418..8f46cb134 100644 --- a/docs/performance-scenarios.md +++ b/docs/performance-scenarios.md @@ -113,13 +113,14 @@ serves a different purpose: R-parity accuracy). They complement it. cluster="unit", n_bootstrap=999, ).fit(data, outcome="y", unit="unit", time="period", - first_treat="first_treat", covariates=["log_pop", "baseline_spend"], - aggregate="all") + first_treat="first_treat", covariates=["log_pop", "baseline_spend"]) + es = results.aggregate("event_study") # replays the fit-time bootstrap ``` - **Operation chain.** (1) `BaconDecomposition.fit()` for TWFE diagnostic; - (2) CS fit with `aggregate="all"` (populates simple, group, event_study); + (2) CS fit followed by post-fit `aggregate("event_study")` (+ `aggregate("group")`; + the 3.x `fit(aggregate="all")` route, removed at 4.0); (3) inspect event-study pre-period ATTs for pre-trends; (4) - `compute_honest_did(results, method="relative_magnitude", M=[0.5, 1.0, 1.5, 2.0])`; + `compute_honest_did(es, method="relative_magnitude", M=[0.5, 1.0, 1.5, 2.0])`; (5) robustness: refit with `SunAbraham()` and `ImputationDiD()` for cross-estimator comparison; (6) refit CS without covariates for the Baker-mandated with/without comparison; (7) `practitioner_next_steps()`. @@ -196,9 +197,10 @@ serves a different purpose: R-parity accuracy). They complement it. CallawaySantAnna(control_group="never_treated", estimation_method="reg", n_bootstrap=199).fit( panel, outcome="y_mean", unit="state", time="year", - first_treat="first_treat", survey_design=stage2, aggregate="all", + first_treat="first_treat", survey_design=stage2, ) - compute_honest_did(results, method="relative_magnitude", M=[0.5, 1.0, 1.5]) + es = results.aggregate("event_study") + compute_honest_did(es, method="relative_magnitude", M=[0.5, 1.0, 1.5]) ``` - **Operation chain.** (1) `aggregate_survey()` - the microdata-to-panel collapse; (2) CS fit with the second-stage SurveyDesign returned by diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index b64157b93..ebe037023 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -212,7 +212,7 @@ Staggered Adoption Issues # Use bootstrap for better inference. Post-fit aggregation works on # BOOTSTRAPPED fits too: results.aggregate('event_study') replays the # fit-time multiplier bootstrap from the retained RNG state (percentile - # inference - no refit and no deprecated fit-time aggregate= needed). + # inference - no refit needed; fit() takes no aggregate= keyword). cs = CallawaySantAnna(n_bootstrap=999) results = cs.fit(data, outcome='y', unit='unit_id', time='period', first_treat='first_treat') diff --git a/docs/tutorials/02_staggered_did.ipynb b/docs/tutorials/02_staggered_did.ipynb index c605038b8..db2043a4f 100644 --- a/docs/tutorials/02_staggered_did.ipynb +++ b/docs/tutorials/02_staggered_did.ipynb @@ -1177,7 +1177,7 @@ "source": [ "# Event study with bootstrap confidence intervals: post-fit aggregation\n", "# replays the fit-time multiplier bootstrap (percentile inference), so no\n", - "# refit and no deprecated fit-time aggregate= are needed.\n", + "# refit is needed (fit() takes no aggregate= keyword since 4.0).\n", "es_boot = results_boot.aggregate('event_study')\n", "\n", "print(\"\\nEvent Study with Bootstrap Inference:\")\n", diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index b593bb007..dc6318b94 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -237,12 +237,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/staggered.py, diff_diff/staggered_results.py, diff_diff/staggered_bootstrap.py, diff_diff/bootstrap_utils.py, diff_diff/bootstrap_chunking.py, diff_diff/aggregation.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt, diff_diff/diagnostic_report.py] - notes: "Shimmed in 3.9: fit(aggregate=) warns via a sentinel default (so a plain fit() never warns) and still returns the fully populated legacy surface; results.aggregate(type=) is the successor. balance_e moves alongside it as its own row [M-117] - it was previously tracked only as prose here, which nothing asserted. VOCABULARY: the closed set is library-wide (simple|event_study|group|calendar|total - 'total' promoted 2026-08-16 as the estimator-owned total incremental outcome, an exact relay C x overall over the finite-masked complete-case support); CallawaySantAnna's SUPPORTED SUBSET is simple|event_study|group|total - it has no calendar aggregator (the DEFERRED 'Calendar-time aggregation' row), and aggregate('calendar') raises naming what is supported; 'total' is panel/non-survey only (RC-routed, declared-survey_design, divergent bare-cluster, and pre-upgrade-kit fits raise NotImplementedError naming the reason; the mass replays from the fit-time agg_gt_cells/is_survey_fit kit snapshots). BOOTSTRAP fits: 'simple' and, where supported, 'total' RELAY the stored overall quintet verbatim (percentile se/p/CI beside the finite safe_inference t; 'total' scales att/se/CI by C) with a NaN df column - the relay levels never re-warn; the recompute levels (event_study/group) REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec (the fit-captured RNG state + run params BY VALUE, so seed=None fits replay and post-fit set_params/attribute mutation cannot alter it; pickles carry it): se/CI/cband match a fit-time aggregation to BLAS reassociation (~1 ULP, assert_allclose - never bit-identity), the discrete percentile P-VALUE is a count statistic carved out of that claim (compared at 2/n_bootstrap), and the container publishes NO analytical provenance (df cleared; vcov/vcov_index are the covariance of the replayed bootstrap draws - diagonal = bootstrap se**2 - or None when n_bootstrap is at or below the number of covered horizons or a draw is non-finite; sup-t cband recomputed from the replayed draws). Each replaying call regenerates the full weight stream and re-runs the fused perturbation GEMM over the per-cell + per-event-time influence columns - O(n_bootstrap x n_units x (n_gt + n_event_times)) FLOPs per call, no memoization (immutability discipline; DR caches its derived surface once per report). The replay re-runs the same warning sites (never suppresses; per-site Python warning-registry semantics govern re-display). BACKEND GUARD: the spec stamps the weight-generation backend at capture ('rust'/'numpy' per bootstrap_chunking.effective_weight_backend, or 'portable' for provably backend-independent branches - stratified/single-PSU survey generation and census-FPC zero weights); Rust and NumPy produce DIFFERENT draws from the same bit-generator state, so a replay under a different backend (DIFF_DIFF_BACKEND flip, missing extension, another machine) fails closed naming both backends rather than silently desynchronizing from the artifact's stored relay quintet. Legacy pickles without the spec fail closed with a refit message. The per-level policy converged with [M-027]; the SDDD engine keeps its own fit-time override-loop copy (unification sequenced with the M-014 container port - twin-drift risk on record). DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits BOOTSTRAPPED INCLUDED (percentile replay; parallel_trends runs the joint Wald test and pretrends_power/sensitivity read the full covariance - the derived container carries the bootstrap-draw covariance as vcov; the Bonferroni/diagonal fallbacks remain where none is stored); derivation failures (kit-less/legacy pickles, backend mismatch, sibling estimators' gates) still surface as explicit skip reasons." + notes: "REMOVED at the 4.0 cut (PR 3b, 2026-10-21). Shimmed in 3.9: fit(aggregate=) warns via a sentinel default (so a plain fit() never warns) and still returns the fully populated legacy surface; results.aggregate(type=) is the successor. balance_e moves alongside it as its own row [M-117] - it was previously tracked only as prose here, which nothing asserted. VOCABULARY: the closed set is library-wide (simple|event_study|group|calendar|total - 'total' promoted 2026-08-16 as the estimator-owned total incremental outcome, an exact relay C x overall over the finite-masked complete-case support); CallawaySantAnna's SUPPORTED SUBSET is simple|event_study|group|total - it has no calendar aggregator (the DEFERRED 'Calendar-time aggregation' row), and aggregate('calendar') raises naming what is supported; 'total' is panel/non-survey only (RC-routed, declared-survey_design, divergent bare-cluster, and pre-upgrade-kit fits raise NotImplementedError naming the reason; the mass replays from the fit-time agg_gt_cells/is_survey_fit kit snapshots). BOOTSTRAP fits: 'simple' and, where supported, 'total' RELAY the stored overall quintet verbatim (percentile se/p/CI beside the finite safe_inference t; 'total' scales att/se/CI by C) with a NaN df column - the relay levels never re-warn; the recompute levels (event_study/group) REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec (the fit-captured RNG state + run params BY VALUE, so seed=None fits replay and post-fit set_params/attribute mutation cannot alter it; pickles carry it): se/CI/cband match a fit-time aggregation to BLAS reassociation (~1 ULP, assert_allclose - never bit-identity), the discrete percentile P-VALUE is a count statistic carved out of that claim (compared at 2/n_bootstrap), and the container publishes NO analytical provenance (df cleared; vcov/vcov_index are the covariance of the replayed bootstrap draws - diagonal = bootstrap se**2 - or None when n_bootstrap is at or below the number of covered horizons or a draw is non-finite; sup-t cband recomputed from the replayed draws). Each replaying call regenerates the full weight stream and re-runs the fused perturbation GEMM over the per-cell + per-event-time influence columns - O(n_bootstrap x n_units x (n_gt + n_event_times)) FLOPs per call, no memoization (immutability discipline; DR caches its derived surface once per report). The replay re-runs the same warning sites (never suppresses; per-site Python warning-registry semantics govern re-display). BACKEND GUARD: the spec stamps the weight-generation backend at capture ('rust'/'numpy' per bootstrap_chunking.effective_weight_backend, or 'portable' for provably backend-independent branches - stratified/single-PSU survey generation and census-FPC zero weights); Rust and NumPy produce DIFFERENT draws from the same bit-generator state, so a replay under a different backend (DIFF_DIFF_BACKEND flip, missing extension, another machine) fails closed naming both backends rather than silently desynchronizing from the artifact's stored relay quintet. Legacy pickles without the spec fail closed with a refit message. The per-level policy converged with [M-027]; the SDDD engine keeps its own fit-time override-loop copy (unification sequenced with the M-014 container port - twin-drift risk on record). DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits BOOTSTRAPPED INCLUDED (percentile replay; parallel_trends runs the joint Wald test and pretrends_power/sensitivity read the full covariance - the derived container carries the bootstrap-draw covariance as vcov; the Bonferroni/diagonal fallbacks remain where none is stored); derivation failures (kit-less/legacy pickles, backend mismatch, sibling estimators' gates) still surface as explicit skip reasons." - id: M-021 kind: param group: aggregate-postfit @@ -279,12 +279,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/efficient_did.py, diff_diff/efficient_did_aggregation.py, diff_diff/efficient_did_bootstrap.py, diff_diff/efficient_did_results.py, diff_diff/aggregation.py, diff_diff/bootstrap_utils.py, diff_diff/bootstrap_chunking.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-120], warn-and-still-work since the params genuinely worked). NO fit-time value validation existed and none is added: unknown strings silently act like None on the deprecated path, unchanged; the post-fit successor fails closed on unknown types via the mixin vocabulary - a behavior improvement. The successor is a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a view relay): fit() computes nothing extra, the results object retains an AggregationKit referencing the per-(g,t) EIF dict (O(n_units x n_gt) dominant payload; full buffer enumeration in the REGISTRY EfficientDiD Note) plus O(n_units) bookkeeping, PRIVATE per-row snapshots of group_time_effects/groups/time_periods plus the pt_assumption/alpha/anticipation/n-total provenance (recompute and the ES carrier never read the mutable public fields), and the POST-OVERALL df_survey snapshot (captured before the ES/group gates - the group pass can degenerate the working df on replicate designs, and every aggregation seeds from the post-overall value), and aggregate('event_study'/'group', balance_e=) recomputes on demand while 'simple' relays the stored overall row bit-exact (n = treated+control units - disjoint by construction; df = the snapshot, provenance-exact where survey_metadata.df_survey can diverge in the degenerate n_valid<=1 replicate state). SUPPORTED SUBSET simple|event_study|group|total ('total' since 2026-08-16: exact relay C x overall with C = the integer sum of kept post-anticipation finite cells' n_treated from the kit's deep-copied group_time_effects snapshot - never the non-integral n_units x sum(cohort_fractions) float product; fits declaring ANY survey_design - the kit's unit_level_weights/resolved_survey_unit markers, weight-type-agnostic - raise NotImplementedError); calendar and 'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' and 'total' RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the recompute levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec (fit-captured RNG state + run params BY VALUE - seed=None fits replay, post-fit set_params/attribute mutation cannot alter it, pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, assert_allclose - never bit-identity), the percentile P-VALUE is a count statistic compared at 2/n_bootstrap, and the replay publishes no analytical provenance (percentile overrides + all-NaN df channels; EDiD has no cband). The ES/group percentile overrides are the SHARED appliers in bootstrap_utils (apply_bootstrap_event_study_overrides/apply_bootstrap_group_overrides, relocated from staggered_bootstrap - one implementation for the CS and EDiD fit paths and replays; verified bit-inert at the swap). Per replaying call the engine regenerates the full weight stream and re-runs the fused perturbation GEMM over the n_gt per-cell EIF columns - O(n_bootstrap x n_units x n_gt) FLOPs, ES/group targets re-aggregated cheaply from the dense (n_bootstrap, n_gt) matrix - no memoization (immutability discipline; DR caches its derived surface once per report). The replay re-runs the same warning sites (re-emits, never suppresses). BACKEND GUARD (the M-020 contract): the spec stamps the weight backend at capture ('rust'/'numpy' per bootstrap_chunking.effective_weight_backend, or 'portable' for provably backend-independent branches - stratified survey generation, census-FPC zero weights, and the single-PSU degenerate early return, which is stamped before any generation); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. FRACTIONAL-PERIOD KEY FIX shipped with the replay: the bootstrap ES prep now keys horizons by the analytical int(t - g) truncation at all three sites (the two horizon keys AND the balance_e anchor filter) - previously raw t - g attached a strict sub-aggregate's percentile inference to the pooled analytical row on fractional panels; n_groups now counts DISTINCT cohorts per bucket (identity on integer panels) and fractional truncation emits a UserWarning (see the REGISTRY truncation Note). The prior uniform-conservatism BY-DECISION rule (no level publishes analytical-provenance fields beside percentile inference) was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale is honored by the percentile overrides and NaN df channels. Group rows record per-row df_used at each safe_inference call (additive public row-dict key; the fit-time bootstrap override clears it - CS precedent); the post-fit group relay publishes the per-row array, a stated divergence from CS's conservative-min scalar broadcast. PT-Post reference provenance: the membership-gated reference_period property (SunAbraham rule - never synthesized when the anchor cell was not estimated) marks the materialized mechanical zero anchor is_reference in the container, and plot_event_study's inferred reference correctly shifts to -1-anticipation on PT-Post anticipation>0 fits. store_eif now governs only the public influence_functions field - the kit ALWAYS retains the EIF dict (memory-contract change; a store_kit opt-out is a DEFERRED row). Container admission NOT widened to EfficientDiD (see M-093); balance_e moves as its own row [M-120]." + notes: "REMOVED at the 4.0 cut (PR 3b, 2026-10-21). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-120], warn-and-still-work since the params genuinely worked). NO fit-time value validation existed and none is added: unknown strings silently act like None on the deprecated path, unchanged; the post-fit successor fails closed on unknown types via the mixin vocabulary - a behavior improvement. The successor is a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a view relay): fit() computes nothing extra, the results object retains an AggregationKit referencing the per-(g,t) EIF dict (O(n_units x n_gt) dominant payload; full buffer enumeration in the REGISTRY EfficientDiD Note) plus O(n_units) bookkeeping, PRIVATE per-row snapshots of group_time_effects/groups/time_periods plus the pt_assumption/alpha/anticipation/n-total provenance (recompute and the ES carrier never read the mutable public fields), and the POST-OVERALL df_survey snapshot (captured before the ES/group gates - the group pass can degenerate the working df on replicate designs, and every aggregation seeds from the post-overall value), and aggregate('event_study'/'group', balance_e=) recomputes on demand while 'simple' relays the stored overall row bit-exact (n = treated+control units - disjoint by construction; df = the snapshot, provenance-exact where survey_metadata.df_survey can diverge in the degenerate n_valid<=1 replicate state). SUPPORTED SUBSET simple|event_study|group|total ('total' since 2026-08-16: exact relay C x overall with C = the integer sum of kept post-anticipation finite cells' n_treated from the kit's deep-copied group_time_effects snapshot - never the non-integral n_units x sum(cohort_fractions) float product; fits declaring ANY survey_design - the kit's unit_level_weights/resolved_survey_unit markers, weight-type-agnostic - raise NotImplementedError); calendar and 'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' and 'total' RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the recompute levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec (fit-captured RNG state + run params BY VALUE - seed=None fits replay, post-fit set_params/attribute mutation cannot alter it, pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, assert_allclose - never bit-identity), the percentile P-VALUE is a count statistic compared at 2/n_bootstrap, and the replay publishes no analytical provenance (percentile overrides + all-NaN df channels; EDiD has no cband). The ES/group percentile overrides are the SHARED appliers in bootstrap_utils (apply_bootstrap_event_study_overrides/apply_bootstrap_group_overrides, relocated from staggered_bootstrap - one implementation for the CS and EDiD fit paths and replays; verified bit-inert at the swap). Per replaying call the engine regenerates the full weight stream and re-runs the fused perturbation GEMM over the n_gt per-cell EIF columns - O(n_bootstrap x n_units x n_gt) FLOPs, ES/group targets re-aggregated cheaply from the dense (n_bootstrap, n_gt) matrix - no memoization (immutability discipline; DR caches its derived surface once per report). The replay re-runs the same warning sites (re-emits, never suppresses). BACKEND GUARD (the M-020 contract): the spec stamps the weight backend at capture ('rust'/'numpy' per bootstrap_chunking.effective_weight_backend, or 'portable' for provably backend-independent branches - stratified survey generation, census-FPC zero weights, and the single-PSU degenerate early return, which is stamped before any generation); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. FRACTIONAL-PERIOD KEY FIX shipped with the replay: the bootstrap ES prep now keys horizons by the analytical int(t - g) truncation at all three sites (the two horizon keys AND the balance_e anchor filter) - previously raw t - g attached a strict sub-aggregate's percentile inference to the pooled analytical row on fractional panels; n_groups now counts DISTINCT cohorts per bucket (identity on integer panels) and fractional truncation emits a UserWarning (see the REGISTRY truncation Note). The prior uniform-conservatism BY-DECISION rule (no level publishes analytical-provenance fields beside percentile inference) was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale is honored by the percentile overrides and NaN df channels. Group rows record per-row df_used at each safe_inference call (additive public row-dict key; the fit-time bootstrap override clears it - CS precedent); the post-fit group relay publishes the per-row array, a stated divergence from CS's conservative-min scalar broadcast. PT-Post reference provenance: the membership-gated reference_period property (SunAbraham rule - never synthesized when the anchor cell was not estimated) marks the materialized mechanical zero anchor is_reference in the container, and plot_event_study's inferred reference correctly shifts to -1-anticipation on PT-Post anticipation>0 fits. store_eif now governs only the public influence_functions field - the kit ALWAYS retains the EIF dict (memory-contract change; a store_kit opt-out is a DEFERRED row). Container admission NOT widened to EfficientDiD (see M-093); balance_e moves as its own row [M-120]." - id: M-024 kind: param group: aggregate-postfit @@ -293,12 +293,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/stacked_did.py, diff_diff/stacked_did_results.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/diagnostic_report.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns - CS-style warn-and-still-work, since the param genuinely worked, unlike dCDH's raise; group/all + unknown values keep their ValueErrors AFTER the warning). The event-study surface is now ALWAYS computed at fit - the pooled regression always included the interactions, and the extraction + per-event Bell-McCaffrey contrast DOFs (hc2_bm) are no longer gated on fit-time aggregate, so the param is behaviorally inert; consequences: plain-fit summary() prints the ES table, to_dataframe('event_study') is total on 3.9+ fits, DiagnosticReport's heterogeneity and (kappa_pre>=2) parallel_trends checks now run on plain fits, and the hc2_bm overall contrast shares the m>1 batch-relative noise floor (fail-closed + warned; REGISTRY Note). Both aggregate() levels are pure VIEWS: 'event_study' -> build_event_study_surface(self); 'simple' relays overall_att/se/t/p/CI bit-exact with target='att' (the scalar is the post-average, NOT the per-event trimmed aggregate ATT), n=n_treated_units/'units' (treated-only scope: control units overlap treated across sub-experiments, so no disjoint total exists), df=inference_df. SUPPORTED SUBSET simple|event_study - group/calendar/total fail closed via the mixin message ('total' joined the vocabulary 2026-08-16; StackedDiD is STAGED OUT of totals - estimand ill-defined under weighting= variants, the DEFERRED remainder); balance_e applies to no level (empty vocabulary); weights= rejected. Every inference mode relays (survey TSL, replicate refit, hc2_bm fail-close). honest/pretrends container admission widened to Stacked-sourced containers (see M-093): base_period='universal' ClassVar + singleton reference_event_times property thread the provenance; analytical fits carry df_survey=None (the resolver reads df_inference, a name Stacked does not use - deliberate: normal-theory FLCI, see the REGISTRY Note and the TODO adapter-naming row's cross-reference)." + notes: "REMOVED at the 4.0 cut (PR 3b, 2026-10-21). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns - CS-style warn-and-still-work, since the param genuinely worked, unlike dCDH's raise; group/all + unknown values keep their ValueErrors AFTER the warning). The event-study surface is now ALWAYS computed at fit - the pooled regression always included the interactions, and the extraction + per-event Bell-McCaffrey contrast DOFs (hc2_bm) are no longer gated on fit-time aggregate, so the param is behaviorally inert; consequences: plain-fit summary() prints the ES table, to_dataframe('event_study') is total on 3.9+ fits, DiagnosticReport's heterogeneity and (kappa_pre>=2) parallel_trends checks now run on plain fits, and the hc2_bm overall contrast shares the m>1 batch-relative noise floor (fail-closed + warned; REGISTRY Note). Both aggregate() levels are pure VIEWS: 'event_study' -> build_event_study_surface(self); 'simple' relays overall_att/se/t/p/CI bit-exact with target='att' (the scalar is the post-average, NOT the per-event trimmed aggregate ATT), n=n_treated_units/'units' (treated-only scope: control units overlap treated across sub-experiments, so no disjoint total exists), df=inference_df. SUPPORTED SUBSET simple|event_study - group/calendar/total fail closed via the mixin message ('total' joined the vocabulary 2026-08-16; StackedDiD is STAGED OUT of totals - estimand ill-defined under weighting= variants, the DEFERRED remainder); balance_e applies to no level (empty vocabulary); weights= rejected. Every inference mode relays (survey TSL, replicate refit, hc2_bm fail-close). honest/pretrends container admission widened to Stacked-sourced containers (see M-093): base_period='universal' ClassVar + singleton reference_event_times property thread the provenance; analytical fits carry df_survey=None (the resolver reads df_inference, a name Stacked does not use - deliberate: normal-theory FLCI, see the REGISTRY Note and the TODO adapter-naming row's cross-reference)." - id: M-025 kind: param group: aggregate-postfit @@ -321,12 +321,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/aggregation.py, diff_diff/results_base.py] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns). The param NEVER computed aggregations on dCDH - every non-None value raised 'reserved for Phase 3' NotImplementedError since introduction - so there is no legacy surface to keep populating: a non-None value now raises ValueError pointing at the post-fit route, and the kwargs-splitting chaisemartin_dhaultfoeuille() wrapper forwards the shim unchanged. The successor is a pure VIEW, not a kit recompute: aggregate('event_study') returns build_event_study_surface(self) (Phase-1 L_max=None fits return the 2-row l=1 view - NOT an error; L_max>=1 the multi-horizon l1_first_switch surface), aggregate('simple') a one-row AggregationResult relaying overall_att/se/t/p/CI bit-exact with target = _estimand_label() (DID_M / DID_1 / delta / the trends-linear first-difference label whose overall row is all-NaN by design) and estimand-aware n/n_kind (N_S switcher_cells at L_max=None; N_1 groups at L_max==1; NaN/None for the delta, which averages horizon-specific N_l). SUPPORTED SUBSET simple|event_study - group/calendar/total fail closed via the mixin message ('total' joined the vocabulary 2026-08-16; no dCDH adoption); balance_e applies to no level here (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. BOOTSTRAP FITS ARE PERMITTED (the library-wide per-level relay rule, since [M-027] converged the recompute adopters' 'simple' relays onto it too) because nothing is recomputed - each row relays the STORED inference: percentile-bootstrap DID_M/DID_1 rows carry NaN df, while the L_max>=2 delta keeps analytical safe_inference with a possibly-finite survey df even under bootstrap (REGISTRY Note, Phase 2 cost-benefit delta SE), resolved by _overall_inference_df from event_study_df / the post-fit-refreshed survey_metadata.df_survey. dCDH placebo/normalized surfaces stay estimator-native; only the aggregation entry point moves. NOTE: the dCDH event-study CONTAINER is deliberately rejected by compute_honest_did/compute_pretrends_power (source-scoped admission; the l1 placebo semantics need honest_did's native dCDH branch) - see M-093." + notes: "REMOVED at the 4.0 cut (PR 3b, 2026-10-21). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns). The param NEVER computed aggregations on dCDH - every non-None value raised 'reserved for Phase 3' NotImplementedError since introduction - so there is no legacy surface to keep populating: a non-None value now raises ValueError pointing at the post-fit route, and the kwargs-splitting chaisemartin_dhaultfoeuille() wrapper forwards the shim unchanged. The successor is a pure VIEW, not a kit recompute: aggregate('event_study') returns build_event_study_surface(self) (Phase-1 L_max=None fits return the 2-row l=1 view - NOT an error; L_max>=1 the multi-horizon l1_first_switch surface), aggregate('simple') a one-row AggregationResult relaying overall_att/se/t/p/CI bit-exact with target = _estimand_label() (DID_M / DID_1 / delta / the trends-linear first-difference label whose overall row is all-NaN by design) and estimand-aware n/n_kind (N_S switcher_cells at L_max=None; N_1 groups at L_max==1; NaN/None for the delta, which averages horizon-specific N_l). SUPPORTED SUBSET simple|event_study - group/calendar/total fail closed via the mixin message ('total' joined the vocabulary 2026-08-16; no dCDH adoption); balance_e applies to no level here (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. BOOTSTRAP FITS ARE PERMITTED (the library-wide per-level relay rule, since [M-027] converged the recompute adopters' 'simple' relays onto it too) because nothing is recomputed - each row relays the STORED inference: percentile-bootstrap DID_M/DID_1 rows carry NaN df, while the L_max>=2 delta keeps analytical safe_inference with a possibly-finite survey df even under bootstrap (REGISTRY Note, Phase 2 cost-benefit delta SE), resolved by _overall_inference_df from event_study_df / the post-fit-refreshed survey_metadata.df_survey. dCDH placebo/normalized surfaces stay estimator-native; only the aggregation entry point moves. NOTE: the dCDH event-study CONTAINER is deliberately rejected by compute_honest_did/compute_pretrends_power (source-scoped admission; the l1 placebo semantics need honest_did's native dCDH branch) - see M-093." - id: M-027 kind: param group: aggregate-postfit @@ -335,12 +335,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/had.py, diff_diff/had_pretests.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-full.txt, diff_diff/guides/llms.txt, diff_diff/guides/llms-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns once, then the legacy routing runs unchanged - the PRE-EXISTING value validation survives, so None/unknown strings still raise ValueError after the warning, and a supplied mode that mismatches the panel shape still raises the shape error). UNLIKE every prior adopter, the param is a MODE SELECTOR, not an aggregation choice: 'overall' (exactly two periods -> HeterogeneousAdoptionDiDResults, scalar WAS) vs 'event_study' (more than two -> the per-horizon HeterogeneousAdoptionDiDEventStudyResults) - mutually exclusive panel shapes, so the SENTINEL resolves by panel-shape INFERENCE (_infer_aggregate_mode: distinct time values <= 2 -> overall, else event_study), running AFTER the time/time_col alias reconciliation (rows M-035..M-039 keep both spellings live). BEHAVIOR DELTA: a plain multi-period fit() previously raised the two-period shape error and now runs the event-study mode (error -> works; no numbers change on any previously-working call). One shape error stays sentinel-reachable: the staggered last-cohort auto-filter can drop a T>2 panel below three periods post-inference (its message no longer teaches the kwarg). POST-FIT SUCCESSOR: both results classes take the M-122 mixin as PURE VIEWS with per-class supported subsets - the overall class supports only 'simple' (one-row bit-exact relay: target = target_parameter, the estimand label 'WAS'/'WAS_d_lower' per the dCDH precedent; n = n_obs, the contributing-unit count, n_kind='units' - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band, so n_obs is the single source; df = resolve_inference_df, provenance-exact: finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable), and the event-study class supports only 'event_study' (build_event_study_surface passthrough via the _from_had adapter, cband fields included). Cross-mode levels fail closed via the mixin vocabulary message - the two modes share no computable estimand (ES fits store no overall WAS; joint cross-horizon covariance is the DEFERRED.md row). NO KIT is retained (views work on pickles from any release) and NO bootstrap gate exists (HAD's n_bootstrap feeds only the sup-t cband critical value; pointwise inference is always analytical). The _from_had adapter's n_kind was corrected 'obs' -> 'units' with this row (n_obs_per_horizon counts units), and AggregationResult.summary() gained the single-non-'att'-target rendering (the estimand-heading widening; see the v4-design section 6 rule). Consumer admission is DEFERRED, not by-design (M-093 amendment): HAD ES coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted (identically zero AND the WAS is not identified there) and no joint covariance exists. The workflow twin did_had_pretest_workflow(aggregate=) is row M-139 (same inference, same release)." + notes: "REMOVED at the 4.0 cut (PR 3b, 2026-10-21). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns once, then the legacy routing runs unchanged - the PRE-EXISTING value validation survives, so None/unknown strings still raise ValueError after the warning, and a supplied mode that mismatches the panel shape still raises the shape error). UNLIKE every prior adopter, the param is a MODE SELECTOR, not an aggregation choice: 'overall' (exactly two periods -> HeterogeneousAdoptionDiDResults, scalar WAS) vs 'event_study' (more than two -> the per-horizon HeterogeneousAdoptionDiDEventStudyResults) - mutually exclusive panel shapes, so the SENTINEL resolves by panel-shape INFERENCE (_infer_aggregate_mode: distinct time values <= 2 -> overall, else event_study), running AFTER the time/time_col alias reconciliation (rows M-035..M-039 keep both spellings live). BEHAVIOR DELTA: a plain multi-period fit() previously raised the two-period shape error and now runs the event-study mode (error -> works; no numbers change on any previously-working call). One shape error stays sentinel-reachable: the staggered last-cohort auto-filter can drop a T>2 panel below three periods post-inference (its message no longer teaches the kwarg). POST-FIT SUCCESSOR: both results classes take the M-122 mixin as PURE VIEWS with per-class supported subsets - the overall class supports only 'simple' (one-row bit-exact relay: target = target_parameter, the estimand label 'WAS'/'WAS_d_lower' per the dCDH precedent; n = n_obs, the contributing-unit count, n_kind='units' - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band, so n_obs is the single source; df = resolve_inference_df, provenance-exact: finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable), and the event-study class supports only 'event_study' (build_event_study_surface passthrough via the _from_had adapter, cband fields included). Cross-mode levels fail closed via the mixin vocabulary message - the two modes share no computable estimand (ES fits store no overall WAS; joint cross-horizon covariance is the DEFERRED.md row). NO KIT is retained (views work on pickles from any release) and NO bootstrap gate exists (HAD's n_bootstrap feeds only the sup-t cband critical value; pointwise inference is always analytical). The _from_had adapter's n_kind was corrected 'obs' -> 'units' with this row (n_obs_per_horizon counts units), and AggregationResult.summary() gained the single-non-'att'-target rendering (the estimand-heading widening; see the v4-design section 6 rule). Consumer admission is DEFERRED, not by-design (M-093 amendment): HAD ES coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted (identically zero AND the WAS is not identified there) and no joint covariance exists. The workflow twin did_had_pretest_workflow(aggregate=) is row M-139 (same inference, same release)." # ---- Contract renames (params) ------------------------------------------- - id: M-030 @@ -1026,7 +1026,7 @@ rows: status: planned phase: 5 code_refs: [diff_diff/staggered_results.py, diff_diff/sun_abraham.py, diff_diff/imputation_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/efficient_did_results.py, diff_diff/continuous_did_results.py, diff_diff/wooldridge_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/lpdid_results.py, diff_diff/staggered_triple_diff_results.py, diff_diff/results.py, diff_diff/had.py, diff_diff/visualization/_event_study.py, diff_diff/honest_did.py, diff_diff/pretrends.py] - notes: "4.0 sentinel retirement + schema enforcement (spec section 5): the n_groups==0 / n_obs==0 reference-row sentinels retire; every estimator's to_dataframe(level='event_study') emits the [M-092] column schema; the plotter / HonestDiD / PreTrendsPower consume the unified surface. Thirteen ES-carrying source modules enumerated file-by-file (results.py covers MultiPeriodDiD + SpilloverDiD) plus the plotter. behavior-at-done requires test_ref. PARTIAL PRE-CUT DELIVERY (2(b) PR-1, with M-026): the consumer half shipped for CS-SOURCED containers - compute_honest_did, compute_pretrends_power (both with SOURCE-SCOPED admission: source == CallawaySantAnnaResults only, rejecting dCDH l1 containers BY DESIGN since their placebo semantics need honest_did's native branch, and every other producer pending its own aggregate() migration) and plot_event_study / plot_honest_event_study (no source scoping - plotting is label-faithful). Admission widening is each later shim PR's methodology decision, not automatic. SECOND PRE-CUT AMENDMENT (2(b) PR-2, with M-024): admission widened to STACKED-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults}; kappa_pre >= 2 required for estimated pre-periods; honest additionally needs a non-singular full retained event-study covariance (pre+post sub-block); withheld-inference rows admitted with a source-scoped warning; the seven producer-derived guard messages now derive the producer from surface.source). Source scoping continues for all other producers. THIRD PRE-CUT AMENDMENT (2(b) PR-3a, with M-023): admission NOT widened to EfficientDiD-sourced containers BY DESIGN (the dCDH precedent) - the PRIMARY ground is the absent joint event-study covariance (container vcov=None; per-row df was all-NaN at that amendment and is finite on analytical survey fits since the 3.12 M-092 completion - the rejection ground is the missing joint covariance, unchanged); reference semantics are regime-dependent (PT-All: no reference row, universal first-period baseline; PT-Post: a materialized mechanical zero anchor at e=-1-anticipation when estimated, marked via the membership-gated reference_period property - never fabricated when absent); both terminal TypeErrors now state the EfficientDiD rejection explicitly; source scoping continues to reject. FOURTH PRE-CUT AMENDMENT (2(b) PR-3b, with M-021/M-022): admission NOT widened by the Imputation/TwoStage migrations - ImputationDiD rejected BY DESIGN (no joint ES covariance; per-horizon Theorem-3 conservative SEs only); TwoStageDiD DEFERRED despite carrying the joint Gardner-GMM covariance on analytical fits (M-092), because its pre-period coefficients are stage-1 residual means (the reference horizon is dropped from the no-intercept Stage-2 design; the zero anchor row is appended mechanically), not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the delta_0=0 normalization into its boundary/bridge constraints - admission awaits a normalization derivation (DEFERRED.md paper-gated row); both terminal TypeErrors state the per-producer grounds. FIFTH PRE-CUT AMENDMENT (2(b) PR-3c, with M-025): admission NOT widened by the ContinuousDiD migration - rejected BY DESIGN on two independent grounds: no joint event-study covariance exists (per-bin IF SEs only), and the binarized bins carry NO reference-period normalization at all (no reference row exists in the surface; each bin is a raw reweighted binarized ATT level), so HonestDiD's delta_0=0 arithmetic has no anchor even in principle without new methodology; both terminal TypeErrors name the ContinuousDiD rejection alongside EfficientDiD/Imputation. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. SIXTH PRE-CUT AMENDMENT (Phase 3(a), with M-010): admission widened to TWFE-SOURCED CALENDAR containers in compute_honest_did + compute_pretrends_power via a dedicated calendar route that reconstructs the native MultiPeriodDiDResults-branch inputs from the container's post_periods partition provenance (single-reference gate; HonestDiD additionally GEOMETRY-SCOPED to chronological partitions - suffix post_periods + last-pre reference - with the pre-existing native non-suffix geometry limitation documented in the REGISTRY HonestDiD Note and the DEFERRED.md transform-or-reject row; PreTrendsPower plumbs reference_period through the gamma-unit helper, string-label degradation included); relative-scale admission unchanged. SEVENTH PRE-CUT AMENDMENT (DML PR-B1): admission widened to DMLDiD-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults, DMLDiDResults}): DMLDiDResults subclasses CallawaySantAnnaResults and its aggregate('event_study') container is built by the INHERITED CS kit channel, so it carries the same staggered ATT(g,t) payload, reference semantics, base_period provenance, and (bootstrapped) replay behavior as a CS container - the same-semantics ground the original CS admission rests on; the producer-derived guard messages treat DMLDiDResults like CS (both expose base_period). DMLDiD adds NO builder (M-092 untouched) and never populates fit-time event_study_effects (its sentinel-retirement obligation is discharged by the shared CS code). Source scoping continues to reject all other producers. This row stays planned: its transition remains the 4.0 sentinel retirement + full-producer schema enforcement." + notes: "4.0 sentinel retirement + schema enforcement (spec section 5): the n_groups==0 / n_obs==0 reference-row sentinels retire; every estimator's to_dataframe(level='event_study') emits the [M-092] column schema; the plotter / HonestDiD / PreTrendsPower consume the unified surface. Thirteen ES-carrying source modules enumerated file-by-file (results.py covers MultiPeriodDiD + SpilloverDiD) plus the plotter. behavior-at-done requires test_ref. PARTIAL PRE-CUT DELIVERY (2(b) PR-1, with M-026): the consumer half shipped for CS-SOURCED containers - compute_honest_did, compute_pretrends_power (both with SOURCE-SCOPED admission: source == CallawaySantAnnaResults only, rejecting dCDH l1 containers BY DESIGN since their placebo semantics need honest_did's native branch, and every other producer pending its own aggregate() migration) and plot_event_study / plot_honest_event_study (no source scoping - plotting is label-faithful). Admission widening is each later shim PR's methodology decision, not automatic. SECOND PRE-CUT AMENDMENT (2(b) PR-2, with M-024): admission widened to STACKED-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults}; kappa_pre >= 2 required for estimated pre-periods; honest additionally needs a non-singular full retained event-study covariance (pre+post sub-block); withheld-inference rows admitted with a source-scoped warning; the seven producer-derived guard messages now derive the producer from surface.source). Source scoping continues for all other producers. THIRD PRE-CUT AMENDMENT (2(b) PR-3a, with M-023): admission NOT widened to EfficientDiD-sourced containers BY DESIGN (the dCDH precedent) - the PRIMARY ground is the absent joint event-study covariance (container vcov=None; per-row df was all-NaN at that amendment and is finite on analytical survey fits since the 3.12 M-092 completion - the rejection ground is the missing joint covariance, unchanged); reference semantics are regime-dependent (PT-All: no reference row, universal first-period baseline; PT-Post: a materialized mechanical zero anchor at e=-1-anticipation when estimated, marked via the membership-gated reference_period property - never fabricated when absent); both terminal TypeErrors now state the EfficientDiD rejection explicitly; source scoping continues to reject. FOURTH PRE-CUT AMENDMENT (2(b) PR-3b, with M-021/M-022): admission NOT widened by the Imputation/TwoStage migrations - ImputationDiD rejected BY DESIGN (no joint ES covariance; per-horizon Theorem-3 conservative SEs only); TwoStageDiD DEFERRED despite carrying the joint Gardner-GMM covariance on analytical fits (M-092), because its pre-period coefficients are stage-1 residual means (the reference horizon is dropped from the no-intercept Stage-2 design; the zero anchor row is appended mechanically), not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the delta_0=0 normalization into its boundary/bridge constraints - admission awaits a normalization derivation (DEFERRED.md paper-gated row); both terminal TypeErrors state the per-producer grounds. FIFTH PRE-CUT AMENDMENT (2(b) PR-3c, with M-025): admission NOT widened by the ContinuousDiD migration - rejected BY DESIGN on two independent grounds: no joint event-study covariance exists (per-bin IF SEs only), and the binarized bins carry NO reference-period normalization at all (no reference row exists in the surface; each bin is a raw reweighted binarized ATT level), so HonestDiD's delta_0=0 arithmetic has no anchor even in principle without new methodology; both terminal TypeErrors name the ContinuousDiD rejection alongside EfficientDiD/Imputation. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. SIXTH PRE-CUT AMENDMENT (Phase 3(a), with M-010): admission widened to TWFE-SOURCED CALENDAR containers in compute_honest_did + compute_pretrends_power via a dedicated calendar route that reconstructs the native MultiPeriodDiDResults-branch inputs from the container's post_periods partition provenance (single-reference gate; HonestDiD additionally GEOMETRY-SCOPED to chronological partitions - suffix post_periods + last-pre reference - with the pre-existing native non-suffix geometry limitation documented in the REGISTRY HonestDiD Note and the DEFERRED.md transform-or-reject row; PreTrendsPower plumbs reference_period through the gamma-unit helper, string-label degradation included); relative-scale admission unchanged. SEVENTH PRE-CUT AMENDMENT (DML PR-B1): admission widened to DMLDiD-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults, DMLDiDResults}): DMLDiDResults subclasses CallawaySantAnnaResults and its aggregate('event_study') container is built by the INHERITED CS kit channel, so it carries the same staggered ATT(g,t) payload, reference semantics, base_period provenance, and (bootstrapped) replay behavior as a CS container - the same-semantics ground the original CS admission rests on; the producer-derived guard messages treat DMLDiDResults like CS (both expose base_period). DMLDiD adds NO builder (M-092 untouched) and never populates fit-time event_study_effects (its sentinel-retirement obligation is discharged by the shared CS code). Source scoping continues to reject all other producers. EIGHTH PRE-CUT AMENDMENT (4.0 cut PR 3b, with M-020/M-023/M-117/M-120): the fit-time aggregate=/balance_e= keywords are gone on CallawaySantAnna and EfficientDiD, so those two producers populate the legacy event_study_effects/group_effects fields on NO fit (analytic and bootstrapped; the post-fit replay is the only bootstrap route) and their to_dataframe(level='event_study'|'group') raises on every fresh fit until this row's schema rewire lands; admission is unchanged by the removals (the ImputationDiD/TwoStageDiD/ContinuousDiD removals follow in PR 3c). This row stays planned: its transition remains the 4.0 sentinel retirement + full-producer schema enforcement." # ---- Behavior policies (schema-tracked, spec-governed; no reality probe) - - id: M-080 @@ -1405,12 +1405,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/staggered.py, diff_diff/staggered_results.py, diff_diff/aggregation.py] - notes: "balance_e moves from fit() onto aggregate() with [M-020]. Previously tracked only as the prose 'balance_e moves to aggregate() in the same PR' inside M-020's notes, which no test could assert - the same un-rowed-obligation class the gating-completeness amendment closed. Applies to event-study aggregation ONLY (the shipped code threads it nowhere else), so aggregate(type='simple'|'group'|'total', balance_e=...) raises rather than silently ignoring it. The other three balance_e sites (ImputationDiD, TwoStageDiD, EfficientDiD) get their own rows in the PRs that migrate them - EfficientDiD's is [M-120]." + notes: "REMOVED at the 4.0 cut (PR 3b, 2026-10-21). balance_e moves from fit() onto aggregate() with [M-020]. Previously tracked only as the prose 'balance_e moves to aggregate() in the same PR' inside M-020's notes, which no test could assert - the same un-rowed-obligation class the gating-completeness amendment closed. Applies to event-study aggregation ONLY (the shipped code threads it nowhere else), so aggregate(type='simple'|'group'|'total', balance_e=...) raises rather than silently ignoring it. The other three balance_e sites (ImputationDiD, TwoStageDiD, EfficientDiD) get their own rows in the PRs that migrate them - EfficientDiD's is [M-120]." - id: M-120 kind: param group: aggregate-postfit @@ -1419,12 +1419,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/efficient_did.py, diff_diff/efficient_did_aggregation.py, diff_diff/efficient_did_bootstrap.py, diff_diff/efficient_did_results.py, diff_diff/aggregation.py, diff_diff/bootstrap_utils.py] - notes: "balance_e moves from fit() onto aggregate() with [M-023] (joint FutureWarning; the M-117 twin). Applies to event-study aggregation only (the mixin default vocabulary), so aggregate(type='simple'|'group'|'total', balance_e=...) raises. EfficientDiD's balance rule is the ANCHOR-HORIZON rule - keep cohorts with a finite effect at the anchor horizon, then retain all their horizons - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference: EDiD anchors on the int(t - g) bucket while CS keys raw t - g (identical on integer-period panels; the REGISTRY truncation Note documents the fractional case), and divergent from ImputationDiD/TwoStageDiD's balanced-window rule (their own rows document theirs). On bootstrapped fits balance_e rides the M-023 replay (the bootstrap ES prep applies the same int-bucketed anchor rule). An anchor no cohort reaches warns (UserWarning, re-emitted on replay) and yields a legal zero-row EventStudyResults container." + notes: "REMOVED at the 4.0 cut (PR 3b, 2026-10-21). balance_e moves from fit() onto aggregate() with [M-023] (joint FutureWarning; the M-117 twin). Applies to event-study aggregation only (the mixin default vocabulary), so aggregate(type='simple'|'group'|'total', balance_e=...) raises. EfficientDiD's balance rule is the ANCHOR-HORIZON rule - keep cohorts with a finite effect at the anchor horizon, then retain all their horizons - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference: EDiD anchors on the int(t - g) bucket while CS keys raw t - g (identical on integer-period panels; the REGISTRY truncation Note documents the fractional case), and divergent from ImputationDiD/TwoStageDiD's balanced-window rule (their own rows document theirs). On bootstrapped fits balance_e rides the M-023 replay (the bootstrap ES prep applies the same int-bucketed anchor rule). An anchor no cohort reaches warns (UserWarning, re-emitted on replay) and yields a legal zero-row EventStudyResults container." - id: M-118 kind: param group: aggregate-postfit @@ -1691,12 +1691,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/had_pretests.py, diff_diff/had.py] - notes: "The workflow twin of [M-027], added as a pre-cut amendment (next free id - the reserved 2(b) pool is spent/earmarked: M-118/M-119/M-120 claimed, M-116/M-121 intentionally unused). did_had_pretest_workflow's aggregate= routed WHICH pretest battery runs on the same 'overall'/'event_study' vocabulary the fit param used; it never calls fit(), so the successor is the SAME panel-shape inference (_infer_aggregate_mode, shared with fit so the two surfaces cannot drift), applied after the workflow's own alias reconciliation. A plain workflow call never warns; supplying ANY value warns once, then the legacy routing runs unchanged (invalid values still raise ValueError after the warning). `new` is null: there is no successor symbol - the HADPretestReport.aggregate FIELD survives as honest output metadata recording which battery ran (SURFACE_ALLOWLIST entry in tests/test_naming_guard.py; the MODES survive 4.0, only the param dies). The mode vocabulary dies from both public surfaces at 4.0." + notes: "REMOVED at the 4.0 cut (PR 3b, 2026-10-21). The workflow twin of [M-027], added as a pre-cut amendment (next free id - the reserved 2(b) pool is spent/earmarked: M-118/M-119/M-120 claimed, M-116/M-121 intentionally unused). did_had_pretest_workflow's aggregate= routed WHICH pretest battery runs on the same 'overall'/'event_study' vocabulary the fit param used; it never calls fit(), so the successor is the SAME panel-shape inference (_infer_aggregate_mode, shared with fit so the two surfaces cannot drift), applied after the workflow's own alias reconciliation. A plain workflow call never warns; supplying ANY value warns once, then the legacy routing runs unchanged (invalid values still raise ValueError after the warning). `new` is null: there is no successor symbol - the HADPretestReport.aggregate FIELD survives as honest output metadata recording which battery ran (SURFACE_ALLOWLIST entry in tests/test_naming_guard.py; the MODES survive 4.0, only the param dies). The mode vocabulary dies from both public surfaces at 4.0." - id: M-140 kind: param diff --git a/docs/v4-design.md b/docs/v4-design.md index 815b6f084..4469e6037 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -631,7 +631,11 @@ provenance beside percentile statistics; a RECOMPUTE level on a bootstrapped fit either REPLAYS the fit-time bootstrap from retained RNG state (CS [M-020], EfficientDiD [M-023], DMLDiD via the inherited CS kit channel [M-093 seventh amendment]) or RAISES - it never silently -returns analytical inference. **View-relay exception (Phase 2b PRs 1-2):** +returns analytical inference. **Amendment (4.0 cut PR 3b, [M-020] [M-023] +[M-117] [M-120]):** on CallawaySantAnna and EfficientDiD the fit-time +`aggregate=`/`balance_e=` branches are deleted outright (the post-fit replay +exists) and their legacy `event_study_effects`/`group_effects` fields are None +after EVERY fit. **View-relay exception (Phase 2b PRs 1-2):** estimators whose `aggregate()` RELAYS stored fields without recomputation need no influence-function kit - there is nothing to re-weight. The retention requirement binds RECOMPUTING estimators (the CallawaySantAnna @@ -1025,7 +1029,7 @@ tests-first PR that flips no rows). so 3b/3c fit the reviewer) (SHIPPED, PR 3a). - 3b - the mechanical removals on the estimators whose post-fit route already exists (CallawaySantAnna, EfficientDiD, StackedDiD, dCDH, HAD, - the HAD pretest workflow). + the HAD pretest workflow) (SHIPPED, PR 3b). - 3c - the ImputationDiD/TwoStageDiD/ContinuousDiD removals plus the bootstrapped-fit relay (section 6 amendment). 4. The three merges: the MultiPeriodDiD class, `MultiPeriodDiDResults`, diff --git a/tests/_capture_v4_aggregate_oracles.py b/tests/_capture_v4_aggregate_oracles.py index 8550d8b33..b99137c99 100644 --- a/tests/_capture_v4_aggregate_oracles.py +++ b/tests/_capture_v4_aggregate_oracles.py @@ -1,15 +1,23 @@ """Capture the pre-removal aggregation oracles for tests/test_aggregate_contract.py. -MUST run on a tree where the fit-time ``aggregate=`` / ``balance_e=`` keywords still -exist (they are removed at the 4.0 cut, PRs 3b/3c), ONCE PER BACKEND:: +HISTORICAL since the 4.0 cut (PR 3b): this script drives the REMOVED fit-time +``aggregate=`` / ``balance_e=`` route on CallawaySantAnna / EfficientDiD / StackedDiD +/ dCDH / HAD (and imports the contract module's retired ``_boot_fit_time`` / +``_efficient_boot_fit_time`` helpers), so it is runnable only at the recorded +``provenance.captured_at_head`` of ``tests/fixtures/v4_aggregate_oracles.json`` +(``8724fecd``, whose ``diff_diff/`` is byte-identical to the capture tree) - the +same convention as ``tests/_capture_v4_merge_ddd_oracles.py``. It is kept as the +fixture's provenance record; re-capturing means checking out that commit, ONCE +PER BACKEND:: DIFF_DIFF_BACKEND=python python tests/_capture_v4_aggregate_oracles.py > numpy.json DIFF_DIFF_BACKEND=rust python tests/_capture_v4_aggregate_oracles.py > rust.json -and the two payloads are merged into ``tests/fixtures/v4_aggregate_oracles.json`` +and merging the two payloads into ``tests/fixtures/v4_aggregate_oracles.json`` (``{"analytic": ..., "boot": {"numpy": ..., "rust": ...}, "provenance": ...}``). -Why. The 4.0 cut (PRs 3b/3c) deletes the deprecated fit-time aggregation route that +Why. The 4.0 cut (PR 3b; Imputation/TwoStage/ContinuousDiD in PR 3c) deletes the +deprecated fit-time aggregation route that ``test_aggregate_contract.py`` used as its NUMERICAL REFERENCE (the ``*_fit_time`` fixtures and ``_boot_fit_time`` helpers): post-fit ``aggregate()`` was asserted equal to ``fit(aggregate=...)``. Once that route is removed there is no independent diff --git a/tests/test_aggregate_contract.py b/tests/test_aggregate_contract.py index 56c43b499..43dd2079f 100644 --- a/tests/test_aggregate_contract.py +++ b/tests/test_aggregate_contract.py @@ -1,30 +1,34 @@ """Behavioral contract for post-fit ``results.aggregate()`` (spec section 6). The ``test_ref`` for every aggregate-postfit ledger row this file pins: -M-020/M-023/M-021/M-022 (the CS / EfficientDiD / Imputation / TwoStage -fit-time ``aggregate=`` shims, deprecated since 3.9), M-024/M-026 (the Stacked / -dCDH shims + view relays), M-025 (the ContinuousDiD shim + MIXED +M-020/M-023/M-021/M-022 (the CS / EfficientDiD fit-time ``aggregate=`` removals +- 4.0 PR 3b - and the Imputation / TwoStage shims, deprecated since 3.9), +M-024/M-026 (the Stacked / dCDH removals + view relays), M-025 (the ContinuousDiD shim + MIXED view/recompute aggregate() - 'simple'/'dose' views, 'event_study' pruned-IF-payload kit on analytic fits), M-117/M-120/M-118/M-119 (``balance_e`` lives on ``aggregate()`` only), M-027/M-139 (the HAD fit + pretest-workflow -mode selectors with panel-shape inference, and the two PURE-VIEW results +mode-selector removals with panel-shape inference, and the two PURE-VIEW results classes) and M-122 (``AggregationResult``). -The headline gate is ORACLE PARITY: the deprecated fit-time route is scheduled -for removal at the 4.0 cut (PRs 3b/3c), so it no longer serves as this file's -numerical reference; every aggregation surface is pinned against +The headline gate is ORACLE PARITY: the deprecated fit-time route that used to +serve as this file's numerical reference is gone on CallawaySantAnna / EfficientDiD +/ StackedDiD / dCDH / HAD (PR 3b) and scheduled for removal on ImputationDiD / +TwoStageDiD / ContinuousDiD (PR 3c), so every aggregation surface is pinned against ``tests/fixtures/v4_aggregate_oracles.json`` - captured on the unmodified tree by -``tests/_capture_v4_aggregate_oracles.py`` (see ``tests/fixtures/README.md``). +``tests/_capture_v4_aggregate_oracles.py`` while that route still existed (see +``tests/fixtures/README.md``). Analytic surfaces must match at ``rtol=1e-12, atol=1e-13``; bootstrapped surfaces (the CS/EfficientDiD replays and the plain-fit overall quintets) match the backend-matched oracle at the replay-parity tolerances (``rtol=1e-13, atol=1e-13`` on att/se/t/CI, ``atol=2/n_bootstrap`` on the count-statistic p-values). Drift here means a regression, not a design change. -The ``Test*Shim`` classes pin the deprecated fit-time keywords' 3.9 FutureWarning -behaviour until the removals land (CS/EfficientDiD/Stacked/dCDH/HAD in PR 3b; -Imputation/TwoStage/ContinuousDiD, together with the bootstrapped-fit relay, in -PR 3c). +Since PR 3b the fit-time keywords are gone outright on CallawaySantAnna, +EfficientDiD, StackedDiD, dCDH and HAD (and ``did_had_pretest_workflow``): +``fit(aggregate=...)`` / ``fit(balance_e=...)`` raise Python's native ``TypeError`` +(the ``Test*Removal`` classes). The remaining ``Test*Shim`` classes pin the +Imputation/TwoStage/ContinuousDiD keywords' 3.9 FutureWarning behaviour until PR 3c +removes them together with the bootstrapped-fit relay. """ import copy @@ -50,6 +54,10 @@ # --------------------------------------------------------------------------- # +def _unexpected(old: str) -> str: + return rf"unexpected keyword argument '{old}'" + + # --------------------------------------------------------------------------- # # Oracle comparison helpers (tests/fixtures/v4_aggregate_oracles.json) # --------------------------------------------------------------------------- # @@ -402,82 +410,14 @@ def panel(): @pytest.fixture(scope="module") def fitted(panel): - """A plain fit - the canonical route (no fit-time aggregation keywords).""" + """A plain fit - the only route since 4.0 (no fit-time aggregation keywords).""" return CallawaySantAnna().fit(panel, **FIT_KW) -@pytest.fixture(scope="module") -def fit_time(panel): - """The DEPRECATED fit-time aggregation, for the inertness comparison.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - return CallawaySantAnna().fit(panel, aggregate="all", **FIT_KW) - - def _cs_oracle(arm): return load_oracles()["analytic"]["CallawaySantAnna"][arm] -class TestInertness: - def test_simple_matches_fit_time(self, fitted, fit_time): - got = fitted.aggregate("simple") - assert np.allclose(got.att[0], fit_time.overall_att, rtol=1e-14, atol=1e-14) - assert np.allclose(got.se[0], fit_time.overall_se, rtol=1e-14, atol=1e-14) - assert np.allclose(got.p_value[0], fit_time.overall_p_value, rtol=1e-14, atol=1e-14) - - def test_group_matches_fit_time(self, fitted, fit_time): - frame = fitted.aggregate("group").to_dataframe() - assert len(frame) == len(fit_time.group_effects) - for _, row in frame.iterrows(): - native = fit_time.group_effects[row["label"]] - for col, key in ( - ("att", "effect"), - ("se", "se"), - ("t_stat", "t_stat"), - ("p_value", "p_value"), - ): - assert np.allclose( - row[col], native[key], rtol=1e-14, atol=1e-14, equal_nan=True - ), f"group[{row['label']}].{col} drifted" - - def test_event_study_matches_fit_time(self, fitted, fit_time): - frame = fitted.aggregate("event_study").to_dataframe() - compared = 0 - for _, row in frame.iterrows(): - if bool(row["is_reference"]): - continue - native = fit_time.event_study_effects[row["event_time"]] - for col, key in ( - ("att", "effect"), - ("se", "se"), - ("t_stat", "t_stat"), - ("p_value", "p_value"), - ): - assert np.allclose( - row[col], native[key], rtol=1e-14, atol=1e-14, equal_nan=True - ), f"event_study[{row['event_time']}].{col} drifted" - compared += 1 - assert compared > 0, "no non-reference event times compared" - - @pytest.mark.parametrize("balance_e", [0, 1, 2]) - def test_balance_e_matches_fit_time(self, panel, fitted, balance_e): - """M-117: balance_e on aggregate() reproduces fit(balance_e=).""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna().fit( - panel, aggregate="event_study", balance_e=balance_e, **FIT_KW - ) - frame = fitted.aggregate("event_study", balance_e=balance_e).to_dataframe() - non_ref = frame[~frame["is_reference"].astype(bool)] - assert len(non_ref) == len( - [e for e in native.event_study_effects if e in set(non_ref["event_time"])] - ) - for _, row in non_ref.iterrows(): - got = native.event_study_effects[row["event_time"]] - assert np.allclose(row["att"], got["effect"], rtol=1e-14, atol=1e-14, equal_nan=True) - assert np.allclose(row["se"], got["se"], rtol=1e-14, atol=1e-14, equal_nan=True) - - # --------------------------------------------------------------------------- # # Oracle parity (the headline gate) - CallawaySantAnna, default panel # --------------------------------------------------------------------------- # @@ -557,7 +497,7 @@ def test_analytic_inference_contract(self, fitted): _assert_analytic_inference_contract(fitted.aggregate(level), alpha=fitted.alpha) def test_legacy_fields_none_after_analytic_fit(self, fitted): - """A plain CS fit materializes no fit-time surface.""" + """4.0 PR 3b: CS never materializes a fit-time surface any more.""" assert fitted.event_study_effects is None assert fitted.group_effects is None assert fitted.event_study_vcov is None and fitted.event_study_df is None @@ -777,22 +717,6 @@ def _boot_fit(data, *, seed=42, n_bootstrap=50, fit_kwargs=None, **ctor): ) -def _boot_fit_time(data, *, seed=42, n_bootstrap=50, aggregate="all", fit_kwargs=None, **ctor): - """The parity REFERENCE: the deprecated fit-time aggregation route. - - The reference is the NATIVE fit-time surface (`event_study_effects` / - `group_effects` dict entries + `cband_crit_value`), never a second - aggregate() call — the kit is attached unconditionally at the end of - fit(), so a fit-time-aggregated result's own aggregate() would ALSO - replay, and replay-vs-replay proves nothing. - """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - return CallawaySantAnna(n_bootstrap=n_bootstrap, seed=seed, **ctor).fit( - data, aggregate=aggregate, **FIT_KW, **(fit_kwargs or {}) - ) - - _CS_NBOOT = 50 @@ -1246,29 +1170,57 @@ def test_non_estimable_row_keeps_point_estimate(self): # --------------------------------------------------------------------------- # -# Fit-time keyword shim (M-020 / M-117; removal lands in PR 3b) +# Fit-time keyword removal (M-020 / M-117) # --------------------------------------------------------------------------- # -class TestFitShim: +class TestFitRemoval: + """M-020 / M-117 removed: the fit-time keywords are gone outright.""" + def test_plain_fit_does_not_warn(self, panel): - """The sentinel default is what makes this true - a bare None default - could not tell 'not passed' from 'passed None'.""" with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") CallawaySantAnna().fit(panel, **FIT_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - @pytest.mark.parametrize("kwargs", [{"aggregate": "group"}, {"balance_e": 1}]) - def test_deprecated_args_warn(self, panel, kwargs): - with pytest.warns(FutureWarning, match="aggregate"): - CallawaySantAnna().fit(panel, **kwargs, **FIT_KW) + @pytest.mark.parametrize("kw", ["aggregate", "balance_e"]) + def test_removed_keyword_is_a_type_error(self, panel, kw): + with pytest.raises(TypeError, match=_unexpected(kw)): + CallawaySantAnna().fit(panel, **{kw: None}, **FIT_KW) + value = "group" if kw == "aggregate" else 1 + with pytest.raises(TypeError, match=_unexpected(kw)): + CallawaySantAnna().fit(panel, **{kw: value}, **FIT_KW) + + def test_signature_has_no_fit_time_aggregation_parameters(self): + params = inspect.signature(CallawaySantAnna.fit).parameters + assert "aggregate" not in params and "balance_e" not in params + assert list(params) == [ + "self", + "data", + "outcome", + "unit", + "time", + "first_treat", + "covariates", + "survey_design", + ] + + def test_seventh_positional_binds_to_survey_design(self): + """No vacated slot: a SurveyDesign in the 7th positional position (after + covariates) is the survey design, not a silently-ignored aggregate.""" + from diff_diff import SurveyDesign - def test_deprecated_path_still_populates_legacy_surface(self, fit_time): - """Downstream consumers (honest_did, pretrends, - build_event_study_surface) read these off the results object.""" - assert fit_time.event_study_effects is not None - assert fit_time.group_effects is not None + d = _survey_panel() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = CallawaySantAnna().fit( + d, "y", "unit", "time", "first_treat", None, _survey_design() + ) + kw = CallawaySantAnna().fit(d, survey_design=_survey_design(), **FIT_KW) + assert res.survey_metadata is not None + assert res.survey_metadata.df_survey == kw.survey_metadata.df_survey + assert res.overall_att == kw.overall_att + assert isinstance(_survey_design(), SurveyDesign) def _rcs(seed=17, n_per_cell=6, n_periods=6): @@ -1372,126 +1324,6 @@ def test_point_estimates_from_public_cells_across_designs(self, panel, ctor): _assert_att_from_cells(post, post.aggregate("event_study"), name=str(ctor)) -class TestInertnessAcrossDesigns: - """The inertness gate on designs the balanced-panel fixture does not reach. - - The refactor made the aggregators pure by threading bookkeeping that the - panel path and the repeated-cross-section path populate differently (RCS - makes several kit keys observation-length rather than unit-length), and - ``anticipation`` shifts which ``(g, t)`` cells are eligible - so both need - their own round-trip, not just the panel. - """ - - @pytest.mark.parametrize("level", ["simple", "group", "event_study"]) - def test_repeated_cross_sections_match_fit_time(self, level): - data = _rcs() - post = CallawaySantAnna(panel=False).fit(data, **FIT_KW) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna(panel=False).fit(data, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) - - @pytest.mark.parametrize("level", ["simple", "group", "event_study", "total"]) - def test_anticipation_matches_fit_time(self, panel, level): - post = CallawaySantAnna(anticipation=1).fit(panel, **FIT_KW) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna(anticipation=1).fit(panel, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) - - @pytest.mark.parametrize("level", ["simple", "group", "event_study", "total"]) - def test_universal_base_period_matches_fit_time(self, panel, level): - """REGISTRY gives universal bases their own reference-cell and - VCV-index semantics (a zero reference cell per cohort), which the - default 'varying' fixture never exercises.""" - kw = dict(base_period="universal") - post = CallawaySantAnna(**kw).fit(panel, **FIT_KW) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna(**kw).fit(panel, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) - - @pytest.mark.parametrize("level", ["simple", "group", "event_study", "total"]) - def test_not_yet_treated_with_anticipation_matches_fit_time(self, panel, level): - """The not-yet-treated control group interacts with anticipation in - picking each (g, t) comparison set - a different code path from the - never-treated default.""" - kw = dict(control_group="not_yet_treated", anticipation=1) - post = CallawaySantAnna(**kw).fit(panel, **FIT_KW) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna(**kw).fit(panel, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) - - @pytest.mark.parametrize("level", ["simple", "group", "event_study"]) - def test_unbalanced_panel_matches_fit_time(self, panel, level): - """allow_unbalanced_panel changes which units survive into the - influence vectors, so the retained bookkeeping must agree with what - the fit actually used.""" - thinned = panel.drop(panel.index[::13]).reset_index(drop=True) - kw = dict(allow_unbalanced_panel=True) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - post = CallawaySantAnna(**kw).fit(thinned, **FIT_KW) - native = CallawaySantAnna(**kw).fit(thinned, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) - - @pytest.mark.parametrize("level", ["simple", "group", "event_study"]) - def test_survey_numbers_match_fit_time(self, survey_fit, level): - """Survey parity on the ESTIMATES, not just the df metadata.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - native = CallawaySantAnna().fit( - _survey_panel(), - survey_design=_survey_design(), - aggregate="all", - **FIT_KW, - ) - _assert_level_matches(survey_fit, native, level) - - -def _assert_level_matches(post, native, level): - """Compare a post-fit aggregation against the fit-time surface at 1e-14.""" - if level == "total": - # Identity form (total == n x overall on the SAME fit) AND the - # independent-mass form (n pinned against a frame-derived oracle from - # the OTHER fit's public cells) - the identity alone passes for ANY C. - got = post.aggregate("total") - antic = getattr(native, "anticipation", 0) - n_exp = sum( - d["n_treated"] - for (g, t), d in native.group_time_effects.items() - if t >= g - antic and np.isfinite(d["effect"]) - ) - assert got.n[0] == float(n_exp) - assert np.allclose(got.att[0], got.n[0] * native.overall_att, rtol=1e-12) - assert np.allclose(got.se[0], got.n[0] * native.overall_se, rtol=1e-12) - assert np.allclose(got.t_stat[0], native.overall_t_stat, rtol=1e-14, atol=0, equal_nan=True) - return - if level == "simple": - got = post.aggregate("simple") - assert np.allclose(got.att[0], native.overall_att, rtol=1e-14, atol=1e-14) - assert np.allclose(got.se[0], native.overall_se, rtol=1e-14, atol=1e-14) - return - - frame = post.aggregate(level).to_dataframe() - if level == "group": - expected, key_col = native.group_effects, "label" - else: - expected, key_col = native.event_study_effects, "event_time" - compared = 0 - for _, row in frame.iterrows(): - if level == "event_study" and bool(row["is_reference"]): - continue - ref = expected[row[key_col]] - for col, key in (("att", "effect"), ("se", "se"), ("p_value", "p_value")): - assert np.allclose( - row[col], ref[key], rtol=1e-14, atol=1e-14, equal_nan=True - ), f"{level}[{row[key_col]}].{col} drifted" - compared += 1 - assert compared > 0, f"no {level} rows compared" - - def _assert_level_matches_oracle(post, orow, level, name=""): """Compare a post-fit aggregation against the pre-removal oracle arm.""" if level == "total": @@ -1585,7 +1417,7 @@ def test_bootstrap_clears_group_df_provenance(self, survey): the retained analytical df described inference that no longer exists. Leaving it finite would claim a t-reference governed percentile-bootstrap numbers - the same false provenance the event-study path already clears. - On a plain fit the CS surface is the REPLAY container (nothing is stored on + Since 4.0 the CS surface is the REPLAY container (nothing is stored on the results), so the pin reads the replayed df column. """ from diff_diff import SurveyDesign @@ -1851,7 +1683,7 @@ def test_staggered_triple_diff_overall_att_es_still_works(): # --------------------------------------------------------------------------- # -# dCDH (row M-026): fit(aggregate=) shim pin + the VIEW-based aggregate() +# dCDH (row M-026): fit(aggregate=) removal pin + the VIEW-based aggregate() # --------------------------------------------------------------------------- # DCDH_KW = dict(outcome="outcome", unit="unit", time="period", treatment="treat") @@ -1902,7 +1734,10 @@ def dcdh_fitted(dcdh_panel): return _fit_dcdh(dcdh_panel) -class TestDcdhShim: +class TestDcdhRemoval: + """M-026 removed: dCDH's fit-time ``aggregate=`` (which never computed + anything) is gone; the 6th positional slot is ``L_max``.""" + def test_plain_fit_does_not_warn(self, dcdh_panel): from diff_diff.chaisemartin_dhaultfoeuille import ChaisemartinDHaultfoeuille @@ -1911,18 +1746,25 @@ def test_plain_fit_does_not_warn(self, dcdh_panel): ChaisemartinDHaultfoeuille().fit(dcdh_panel, **DCDH_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, dcdh_panel): + @pytest.mark.parametrize("value", [None, "event_study"]) + def test_aggregate_keyword_is_a_type_error(self, dcdh_panel, value): from diff_diff.chaisemartin_dhaultfoeuille import ChaisemartinDHaultfoeuille - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - ChaisemartinDHaultfoeuille().fit(dcdh_panel, aggregate=None, **DCDH_KW) + with pytest.raises(TypeError, match=_unexpected("aggregate")): + ChaisemartinDHaultfoeuille().fit(dcdh_panel, aggregate=value, **DCDH_KW) - def test_non_none_value_warns_then_raises(self, dcdh_panel): + def test_signature_has_no_aggregate_and_sixth_positional_is_l_max(self, dcdh_panel): from diff_diff.chaisemartin_dhaultfoeuille import ChaisemartinDHaultfoeuille - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match=r"results\.aggregate"): - ChaisemartinDHaultfoeuille().fit(dcdh_panel, aggregate="event_study", **DCDH_KW) + params = list(inspect.signature(ChaisemartinDHaultfoeuille.fit).parameters) + assert "aggregate" not in params and "balance_e" not in params + assert params[6] == "L_max" + res = _fit_dcdh(dcdh_panel) # noqa: F841 - plain fit still works + positional = ChaisemartinDHaultfoeuille().fit( + dcdh_panel, "outcome", "unit", "period", "treat", 2 + ) + assert list(positional.aggregate("simple").target) == ["delta"] + assert 2 in positional.aggregate("event_study").event_time.tolist() class TestDcdhAggregate: @@ -2115,7 +1957,7 @@ def test_mixin_hooks_are_not_dataclass_fields(self): # --------------------------------------------------------------------------- # -# StackedDiD (row M-024): fit(aggregate=) shim pin + the VIEW-based aggregate() +# StackedDiD (row M-024): fit(aggregate=) removal pin + the VIEW-based aggregate() # --------------------------------------------------------------------------- # STACKED_KW = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") @@ -2150,7 +1992,10 @@ def stacked_fitted(stacked_panel): return _fit_stacked(stacked_panel) -class TestStackedShim: +class TestStackedRemoval: + """M-024 removed: the inert fit-time ``aggregate=`` is gone (the ES surface + is always materialized); the 6th positional slot is ``population``.""" + def test_plain_fit_does_not_warn(self, stacked_panel): from diff_diff import StackedDiD @@ -2159,35 +2004,36 @@ def test_plain_fit_does_not_warn(self, stacked_panel): StackedDiD(kappa_pre=2, kappa_post=2).fit(stacked_panel, **STACKED_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, stacked_panel): + @pytest.mark.parametrize("value", [None, "event_study", "group"]) + def test_aggregate_keyword_is_a_type_error(self, stacked_panel, value): from diff_diff import StackedDiD - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - StackedDiD(kappa_pre=2, kappa_post=2).fit(stacked_panel, aggregate=None, **STACKED_KW) + with pytest.raises(TypeError, match=_unexpected("aggregate")): + StackedDiD(kappa_pre=2, kappa_post=2).fit(stacked_panel, aggregate=value, **STACKED_KW) - def test_deprecated_value_warns_and_still_works(self, stacked_panel, stacked_fitted): - # CS-style warn-and-still-work (the param genuinely worked here, - # unlike dCDH's raise): the deprecated path returns an object whose - # surface equals a plain fit's - the surface is always computed. + def test_signature_has_no_aggregate_and_sixth_positional_is_population( + self, stacked_panel, stacked_fitted + ): from diff_diff import StackedDiD - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - res = StackedDiD(kappa_pre=2, kappa_post=2).fit( - stacked_panel, aggregate="event_study", **STACKED_KW + params = list(inspect.signature(StackedDiD.fit).parameters) + assert "aggregate" not in params + assert params[6] == "population" + panel = stacked_panel.copy() + panel["pop"] = 100.0 + (panel["unit"] % 7) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + positional = StackedDiD(kappa_pre=2, kappa_post=2, weighting="population").fit( + panel, "outcome", "unit", "period", "first_treat", "pop" ) - assert res.overall_att == stacked_fitted.overall_att - assert res.event_study_effects is not None - assert sorted(res.event_study_effects) == sorted(stacked_fitted.event_study_effects) - np.testing.assert_array_equal(res.event_study_vcov, stacked_fitted.event_study_vcov) - - def test_group_warns_then_raises_educational_error(self, stacked_panel): - from diff_diff import StackedDiD - - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with pytest.raises(ValueError, match="not supported by StackedDiD"): - StackedDiD(kappa_pre=2, kappa_post=2).fit( - stacked_panel, aggregate="group", **STACKED_KW - ) + keyword = StackedDiD(kappa_pre=2, kappa_post=2, weighting="population").fit( + panel, population="pop", **STACKED_KW + ) + assert positional.overall_att == keyword.overall_att + assert positional.overall_se == keyword.overall_se + # The surface is always computed - with or without a population column. + assert positional.event_study_effects is not None + assert stacked_fitted.event_study_effects is not None class TestStackedAggregate: @@ -2434,7 +2280,7 @@ def test_unsupported_types_fail_closed(self, stacked_fitted, bad): # --------------------------------------------------------------------------- # -# EfficientDiD (rows M-023/M-120): fit(aggregate=/balance_e=) shim pins + the +# EfficientDiD (rows M-023/M-120): fit(aggregate=/balance_e=) removal pins + the # lazy-KIT recompute aggregate() (the CallawaySantAnna class) # --------------------------------------------------------------------------- # @@ -2513,17 +2359,13 @@ def efficient_fitted(efficient_panel): return _fit_efficient(efficient_panel) -@pytest.fixture(scope="module") -def efficient_fit_time(efficient_panel): - """Deprecated fit-time aggregate="all" - the inertness reference.""" - return _fit_efficient(efficient_panel, aggregate="all") - - def _ed_oracle(arm): return load_oracles()["analytic"]["EfficientDiD"][arm] -class TestEfficientShim: +class TestEfficientRemoval: + """M-023 / M-120 removed.""" + def test_plain_fit_does_not_warn(self, efficient_panel): from diff_diff import EfficientDiD @@ -2532,49 +2374,38 @@ def test_plain_fit_does_not_warn(self, efficient_panel): EfficientDiD().fit(efficient_panel, **EFFICIENT_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, efficient_panel): - from diff_diff import EfficientDiD - - with pytest.warns(FutureWarning, match=r"EfficientDiD\.fit\(aggregate=\)"): - EfficientDiD().fit(efficient_panel, **EFFICIENT_KW, aggregate=None) - - def test_balance_e_kwarg_warns_alone(self, efficient_panel): + @pytest.mark.parametrize("kw", ["aggregate", "balance_e"]) + def test_removed_keyword_is_a_type_error(self, efficient_panel, kw): from diff_diff import EfficientDiD - with pytest.warns(FutureWarning, match=r"EfficientDiD\.fit\(balance_e=\)"): - EfficientDiD().fit(efficient_panel, **EFFICIENT_KW, balance_e=None) + with pytest.raises(TypeError, match=_unexpected(kw)): + EfficientDiD().fit(efficient_panel, **EFFICIENT_KW, **{kw: None}) + value = "event_study" if kw == "aggregate" else 0 + with pytest.raises(TypeError, match=_unexpected(kw)): + EfficientDiD().fit(efficient_panel, **EFFICIENT_KW, **{kw: value}) - def test_joint_supply_warns_once_naming_both(self, efficient_panel): + def test_joint_supply_names_the_first_unexpected_keyword(self, efficient_panel): from diff_diff import EfficientDiD - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") + with pytest.raises(TypeError, match="unexpected keyword argument"): EfficientDiD().fit( efficient_panel, **EFFICIENT_KW, aggregate="event_study", balance_e=0 ) - fw = [w for w in caught if issubclass(w.category, FutureWarning)] - assert len(fw) == 1 - msg = str(fw[0].message) - assert "aggregate=" in msg and "balance_e=" in msg - def test_unknown_string_still_acts_like_none(self, efficient_panel): - # The legacy path performs NO value validation - an unknown string - # behaves exactly like a plain fit (documented unchanged behavior; - # the post-fit mixin is what fails closed on unknown types). - res = _fit_efficient(efficient_panel, aggregate="nonsense") - plain = _fit_efficient(efficient_panel) - assert res.event_study_effects is None and res.group_effects is None - assert res.overall_att == plain.overall_att + def test_signature_and_seventh_positional_is_survey_design(self): + from diff_diff import EfficientDiD - def test_warn_and_still_work(self, efficient_panel, efficient_fitted, efficient_fit_time): - # The deprecated path still populates the fit-time surfaces AND - # equals the plain fit's post-fit recompute. - assert efficient_fit_time.event_study_effects is not None - assert efficient_fit_time.group_effects is not None - es = efficient_fitted.aggregate("event_study") - for e, row in efficient_fit_time.event_study_effects.items(): - i = list(es.event_time).index(e) - np.testing.assert_allclose(row["effect"], es.att[i], rtol=1e-14) + params = list(inspect.signature(EfficientDiD.fit).parameters) + assert "aggregate" not in params and "balance_e" not in params + assert params[7] == "survey_design" + d, _ = _efficient_survey_panel() + sd = _efficient_survey_design() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + positional = EfficientDiD().fit(d, "outcome", "unit", "period", "first_treat", None, sd) + keyword = EfficientDiD().fit(d, survey_design=sd, **EFFICIENT_KW) + assert positional.survey_metadata is not None + assert positional.overall_se == keyword.overall_se class TestEfficientAggregate: @@ -2741,7 +2572,7 @@ def test_pt_post_reference_anchor(self, efficient_panel): _assert_att_from_cells(plain, es, bucket=True, name="edid/pt_post") def test_legacy_fields_none_after_every_fit(self, efficient_panel, efficient_fitted): - """A plain EfficientDiD fit stores no fit-time surface (analytic OR + """4.0 PR 3b: EfficientDiD never stores a fit-time surface (analytic OR bootstrapped) - the kit recompute / replay serves both.""" assert efficient_fitted.event_study_effects is None assert efficient_fitted.group_effects is None @@ -2972,16 +2803,6 @@ def _efficient_boot_fit(data, **fit_kw): return _fit_efficient(data, est_kw={"n_bootstrap": _EDID_NBOOT, "seed": 42, **est_kw}, **fit_kw) -def _efficient_boot_fit_time(data, *, aggregate="all", **fit_kw): - est_kw = fit_kw.pop("est_kw", {}) - return _fit_efficient( - data, - est_kw={"n_bootstrap": _EDID_NBOOT, "seed": 42, **est_kw}, - aggregate=aggregate, - **fit_kw, - ) - - def _ed_boot_oracle(arm): return boot_oracles()["EfficientDiD"][arm] @@ -5226,7 +5047,11 @@ def had_fitted_multi(had_panel_multi): return _fit_had(had_panel_multi, first_treat="ft") -class TestHadShim: +class TestHadRemoval: + """M-027 / M-139 removed: the mode selector is gone from fit() and the + workflow; panel-shape inference (``_infer_aggregate_mode``) is the only + rule. The ``HADPretestReport.aggregate`` FIELD survives.""" + def test_plain_fit_2p_infers_overall_without_warning(self, had_panel_2p): from diff_diff import HeterogeneousAdoptionDiD, HeterogeneousAdoptionDiDResults @@ -5251,42 +5076,37 @@ def test_plain_fit_multi_infers_event_study_without_warning(self, had_panel_mult assert isinstance(res, HeterogeneousAdoptionDiDEventStudyResults) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - @pytest.mark.parametrize("mode", ["overall", "event_study"]) - def test_supplied_mode_warns_and_still_works(self, had_panel_2p, had_panel_multi, mode): - from diff_diff import HeterogeneousAdoptionDiD - - data = had_panel_2p if mode == "overall" else had_panel_multi - kw = {} if mode == "overall" else {"first_treat": "ft"} - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - HeterogeneousAdoptionDiD().fit(data, **HAD_KW, aggregate=mode, **kw) - - def test_aggregate_none_warns_then_raises(self, had_panel_2p): - # Unlike ContinuousDiD, None is NOT in HAD's _VALID_AGGREGATES: an - # explicit None is a supplied INVALID value - a third distinct - # behavior beside omitted (infers) and supplied-valid (legacy). - from diff_diff import HeterogeneousAdoptionDiD - - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="Invalid aggregate=None"): - HeterogeneousAdoptionDiD().fit(had_panel_2p, **HAD_KW, aggregate=None) - - def test_invalid_value_warns_then_raises(self, had_panel_2p): + @pytest.mark.parametrize("value", [None, "overall", "event_study", "bogus"]) + def test_aggregate_keyword_is_a_type_error(self, had_panel_2p, had_panel_multi, value): from diff_diff import HeterogeneousAdoptionDiD - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="Invalid aggregate='bogus'"): - HeterogeneousAdoptionDiD().fit(had_panel_2p, **HAD_KW, aggregate="bogus") + with pytest.raises(TypeError, match=_unexpected("aggregate")): + HeterogeneousAdoptionDiD().fit(had_panel_2p, **HAD_KW, aggregate=value) + with pytest.raises(TypeError, match=_unexpected("aggregate")): + HeterogeneousAdoptionDiD().fit( + had_panel_multi, **HAD_KW, first_treat="ft", aggregate=value + ) - def test_supplied_overall_on_multi_warns_then_shape_error(self, had_panel_multi): + def test_signature_and_eighth_positional_is_a_type_error(self, had_panel_2p): + """fit() takes 7 positionals (data..first_treat); cband and the rest + are keyword-only, so no vacated slot can swallow a stray mode.""" from diff_diff import HeterogeneousAdoptionDiD - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="exactly two time periods"): - HeterogeneousAdoptionDiD().fit( - had_panel_multi, **HAD_KW, aggregate="overall", first_treat="ft" - ) + params = inspect.signature(HeterogeneousAdoptionDiD.fit).parameters + assert "aggregate" not in params + assert [n for n, p in params.items() if p.kind is p.POSITIONAL_OR_KEYWORD] == [ + "self", + "data", + "outcome", + "dose", + "time", + "unit", + "first_treat", + ] + with pytest.raises(TypeError, match="positional argument"): + HeterogeneousAdoptionDiD().fit( + had_panel_2p, "outcome", "dose", "period", "unit", None, "overall" + ) def test_sentinel_post_filter_shape_error_no_warning(self): # Sentinel-reachable shape error: T>2 raw panel infers event_study, @@ -5312,59 +5132,30 @@ def test_missing_time_column_still_value_error(self, had_panel_2p): had_panel_2p, outcome="outcome", dose="dose", time="missing", unit="unit" ) - @pytest.mark.parametrize("shape", ["2p", "multi"]) - def test_inference_equivalence_bit_identical(self, had_panel_2p, had_panel_multi, shape): - """Plain fit ≡ fit(aggregate=) field-for-field per shape.""" - if shape == "2p": - plain = _fit_had(had_panel_2p) - legacy = _fit_had(had_panel_2p, aggregate="overall") - else: - plain = _fit_had(had_panel_multi, first_treat="ft") - legacy = _fit_had(had_panel_multi, first_treat="ft", aggregate="event_study") - import dataclasses - - assert type(plain) is type(legacy) - for f in dataclasses.fields(plain): - a, b = getattr(plain, f.name), getattr(legacy, f.name) - if isinstance(a, np.ndarray): - np.testing.assert_array_equal(a, b) - elif isinstance(a, float): - assert (a == b) or (np.isnan(a) and np.isnan(b)), f.name - elif isinstance(a, (list, tuple)) and a and isinstance(a[0], float): - np.testing.assert_array_equal(np.asarray(a), np.asarray(b)) - elif f.name in ( - "bandwidth_diagnostics", - "bias_corrected_fit", - "survey_metadata", - "filter_info", - ): - assert (a is None) == (b is None), f.name - else: - assert a == b, f.name - def test_workflow_plain_call_infers_without_warning(self, had_panel_2p): from diff_diff import did_had_pretest_workflow with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") rep = did_had_pretest_workflow(had_panel_2p, **HAD_KW, n_bootstrap=99, seed=3) - assert rep.aggregate == "overall" + assert rep.aggregate == "overall" # the REPORT field survives assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_workflow_supplied_mode_warns(self, had_panel_2p): + @pytest.mark.parametrize("value", ["overall", "junk"]) + def test_workflow_aggregate_keyword_is_a_type_error(self, had_panel_2p, value): from diff_diff import did_had_pretest_workflow - with pytest.warns(FutureWarning, match=r"workflow\(aggregate=\) is deprecated"): + with pytest.raises(TypeError, match=_unexpected("aggregate")): did_had_pretest_workflow( - had_panel_2p, **HAD_KW, n_bootstrap=99, seed=3, aggregate="overall" + had_panel_2p, **HAD_KW, n_bootstrap=99, seed=3, aggregate=value ) - def test_workflow_invalid_value_warns_then_raises(self, had_panel_2p): + def test_workflow_signature_has_no_aggregate(self): from diff_diff import did_had_pretest_workflow - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="aggregate must be one of"): - did_had_pretest_workflow(had_panel_2p, **HAD_KW, aggregate="junk") + params = inspect.signature(did_had_pretest_workflow).parameters + assert "aggregate" not in params + assert list(params)[:6] == ["data", "outcome", "dose", "time", "unit", "first_treat"] class TestHadAggregate: diff --git a/tests/test_bootstrap_chunking.py b/tests/test_bootstrap_chunking.py index 07ee692a8..f562097b2 100644 --- a/tests/test_bootstrap_chunking.py +++ b/tests/test_bootstrap_chunking.py @@ -329,7 +329,7 @@ def _panel(): return pd.DataFrame({"unit": units, "period": periods, "y": y, "first_treat": ft}) def _fit(self): - # A plain fit stores no fit-time surface; the post-fit aggregate() REPLAYS the + # 4.0: the fit-time surface is gone; the post-fit aggregate() REPLAYS the # bootstrap, so it runs here - inside whatever monkeypatch the caller set. res = CallawaySantAnna( control_group="never_treated", @@ -621,7 +621,7 @@ def fit(): def _with_aggregates(res): """``(results, aggregate("event_study"), aggregate("group"))`` for a bootstrapped fit. - A plain fit stores no fit-time surface, so each post-fit ``aggregate()`` call + Since 4.0 the fit-time surface is gone and each post-fit ``aggregate()`` call REPLAYS the multiplier bootstrap, so the containers are materialized right after the fit - inside the caller's chunk/tile monkeypatch context. """ @@ -725,7 +725,7 @@ def _fit(self, cluster=None, survey_design=None): "first_treat", survey_design=survey_design, ) - # A plain EfficientDiD fit stores no fit-time surface; the post-fit + # 4.0: EfficientDiD stores no fit-time surface; the post-fit # containers replay the bootstrap under the caller's monkeypatch. assert res.event_study_effects is None and res.group_effects is None return _with_aggregates(res) diff --git a/tests/test_business_report.py b/tests/test_business_report.py index 84195c820..6119125bf 100644 --- a/tests/test_business_report.py +++ b/tests/test_business_report.py @@ -96,7 +96,7 @@ def cs_fit(): # base_period='universal' so DR's sensitivity check can run without # hitting the round-5 methodology-critical skip (Rambachan-Roth bounds # are not interpretable on consecutive-comparison pre-periods). - # Plain fit, no fit-time surface - the report layer derives the event study + # 4.0: no fit-time surface - the report layer derives the event study # via results.aggregate("event_study"). cs = CallawaySantAnna(base_period="universal").fit( sdf, diff --git a/tests/test_chaisemartin_dhaultfoeuille.py b/tests/test_chaisemartin_dhaultfoeuille.py index 0d886dff4..ddc05147f 100644 --- a/tests/test_chaisemartin_dhaultfoeuille.py +++ b/tests/test_chaisemartin_dhaultfoeuille.py @@ -242,32 +242,19 @@ def data(self): def _est(self): return ChaisemartinDHaultfoeuille() - def test_aggregate_simple_raises_value_error(self, data): - # M-026: fit(aggregate=) is deprecated and never computed anything; - # a non-None value warns then raises ValueError pointing at the - # post-fit results.aggregate() route. - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="results.aggregate"): - self._est().fit( - data, - outcome="outcome", - unit="group", - time="period", - treatment="treatment", - aggregate="simple", - ) - - def test_aggregate_event_study_raises_value_error(self, data): - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="results.aggregate"): - self._est().fit( - data, - outcome="outcome", - unit="group", - time="period", - treatment="treatment", - aggregate="event_study", - ) + def test_fit_time_aggregate_kwarg_removed(self, data): + # 4.0 (M-026): fit(aggregate=) never computed anything and is gone; + # supplying it is a native TypeError. Aggregations are post-fit + # results.aggregate(...). + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + self._est().fit( + data, + outcome="outcome", + unit="group", + time="period", + treatment="treatment", + aggregate="event_study", + ) def test_L_max_validation(self, data): """L_max is now a Phase 2 feature: positive int or None accepted, diff --git a/tests/test_csdid_ported.py b/tests/test_csdid_ported.py index 8f3c70030..b3fe9b771 100644 --- a/tests/test_csdid_ported.py +++ b/tests/test_csdid_ported.py @@ -193,7 +193,7 @@ def test_two_period_all_aggregations(self, ci_params): assert ( abs(results.overall_att - 1.0) < 0.5 ), f"Simple ATT = {results.overall_att:.3f}, expected ≈ 1.0" - # A plain fit stores no aggregation surface; both levels are post-fit + # 4.0: the fit stores no aggregation surface; both levels are post-fit assert results.event_study_effects is None and results.group_effects is None # Event study: post-treatment effect ≈ 1 post_effects = {e: v for e, v in es_dict(results).items() if e >= 0} diff --git a/tests/test_efficient_did.py b/tests/test_efficient_did.py index 43ccac526..44a64991b 100644 --- a/tests/test_efficient_did.py +++ b/tests/test_efficient_did.py @@ -346,8 +346,12 @@ def test_summary_and_dataframe(self): def test_to_dataframe_raises_without_aggregation(self): df = _make_simple_panel() result = EfficientDiD().fit(df, "y", "unit", "time", "first_treat") - with pytest.raises(ValueError, match="Event study effects not computed"): + # 4.0: the legacy fit-time field is never populated; the error + # steers to the post-fit container route. + with pytest.raises(ValueError, match=r"not stored on 4.0 fits.*aggregate\('event_study'\)"): result.to_dataframe("event_study") + with pytest.raises(ValueError, match=r"not stored on 4.0 fits.*aggregate\('group'\)"): + result.to_dataframe("group") def test_repr(self): df = _make_simple_panel() @@ -646,7 +650,7 @@ def _cov_df(n_units=200, n_periods=9, seed=42): @staticmethod def _surfaces(res): gt = {k: (v["effect"], v["se"]) for k, v in res.group_time_effects.items()} - # post-fit container (a plain fit stores no fit-time surface) + # post-fit container (the fit-time surface is gone since 4.0) es = {k: (v["effect"], v["se"]) for k, v in es_dict(res).items()} return gt, es, (res.overall_att, res.overall_se) @@ -1904,7 +1908,7 @@ def test_bootstrap_with_aggregation(self, ci_params): df, "y", "unit", "time", "first_treat" ) assert result.bootstrap_results is not None - # the legacy fit-time fields are None on a plain EDiD fit; the + # the legacy fit-time fields are None on EVERY 4.0 EDiD fit; the # post-fit aggregate() REPLAYS the fit-time bootstrap assert result.event_study_effects is None assert result.group_effects is None @@ -3072,7 +3076,7 @@ def test_bootstrap_cluster_hc1_bit_equal(self, ci_params): r_default.bootstrap_results.overall_att_se == r_explicit.bootstrap_results.overall_att_se ) - # A plain EDiD fit computes no aggregated bootstrap SEs; the + # A plain 4.0 EDiD fit computes no aggregated bootstrap SEs; the # post-fit aggregate() REPLAYS the fit-time bootstrap, so the # per-horizon / per-group override branches are compared on the # replayed containers. diff --git a/tests/test_event_study_consumers.py b/tests/test_event_study_consumers.py index f05568e8a..e155a0e1d 100644 --- a/tests/test_event_study_consumers.py +++ b/tests/test_event_study_consumers.py @@ -61,7 +61,7 @@ def _panel(seed=11, n_units=80, n_periods=8): def _fit_cs(data, **cs_kw): - # A plain CS fit stores no event-study surface. The route-parity tests + # 4.0: no CS fit stores an event-study surface. The route-parity tests # compare the consumers' NATIVE (raw-field, legacy-pickle) branch against # the container route on the same fit, so the native side is a stand-in # carrying the container's surface in the 3.x fields (dict + vcov + diff --git a/tests/test_event_study_surface.py b/tests/test_event_study_surface.py index 02348dc36..ac9cf7b1d 100644 --- a/tests/test_event_study_surface.py +++ b/tests/test_event_study_surface.py @@ -266,15 +266,15 @@ class _Fake: def surfaces(): """Build every producer's surface once; return {label: (native, surface)}. - Producer routes as they stand in this (tests-first) migration step: the - CallawaySantAnna "native" side is a ``legacy_standin`` carrying - ``aggregate("event_study")`` in the 3.x fields (a plain CS fit stores no - fit-time dict; the stand-in is the legacy-pickle shape - ``build_event_study_surface`` still accepts), while the ImputationDiD, - TwoStageDiD, ContinuousDiD and EfficientDiD producers still use the + Producer routes as they stand after 4.0 PR 3b: the CallawaySantAnna and + EfficientDiD "native" sides are ``legacy_standin``s carrying + ``aggregate("event_study")`` in the 3.x fields (neither fit stores a + fit-time dict any more - the keyword is gone; the stand-in is the + legacy-pickle shape ``build_event_study_surface`` still accepts), while the + ImputationDiD, TwoStageDiD and ContinuousDiD producers still use the deprecated fit-time ``aggregate=`` route, which populates the dict directly. - Those sites move to stand-ins / the post-fit relay when the fit-time - keywords are removed (4.0 PRs 3b/3c). + Those sites move to stand-ins / the post-fit relay when their fit-time + keywords are removed (4.0 PR 3c). """ out = {} @@ -398,9 +398,9 @@ def surfaces(): # 8. EfficientDiD ed_data = _efficient_panel() - ed = EfficientDiD().fit( - ed_data, "y", "unit", "time", "first_treat", aggregate="event_study" - ) + ed = EfficientDiD().fit(ed_data, "y", "unit", "time", "first_treat") + assert ed.event_study_effects is None # 4.0 (PR 3b): never populated at fit + ed = legacy_standin(ed) out["EfficientDiD"] = (ed, build_event_study_surface(ed)) # 9. WooldridgeDiD (post-fit aggregate) diff --git a/tests/test_guides.py b/tests/test_guides.py index 240af2c7a..cb588db19 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -296,6 +296,31 @@ def test_module_docstring_mentions_helper(): # --------------------------------------------------------------------------- # llms-full.txt — HeterogeneousAdoptionDiD coverage (Phase 5) # --------------------------------------------------------------------------- +def _assert_no_fit_time_aggregation_lines(fit_block: str, label: str) -> None: + """4.0 (PR 3b): a documented fit() signature whose fit-time ``aggregate`` / + ``balance_e`` keywords were removed carries no such line - aggregation is + post-fit only there.""" + offending = [ + line + for line in fit_block.splitlines() + if line.strip().startswith(("aggregate", "balance_e")) + ] + assert ( + offending == [] + ), f"{label}: fit-time aggregation keyword(s) still documented: {offending}" + + +def test_llms_full_callaway_fit_block_has_no_fit_time_aggregate(): + # M-020 / M-117 (removed at 4.0, PR 3b): the CallawaySantAnna signature block + # lost its `aggregate: str = None` / `balance_e: int = None` lines. + text = get_llm_guide("full") + start = text.index("### CallawaySantAnna") + section = text[start : text.index("\n### ", start + 1)] + fit_start = section.index(".fit(") + fit_block = section[fit_start : section.index("\n)", fit_start)] + _assert_no_fit_time_aggregation_lines(fit_block, "CallawaySantAnna") + + class TestLLMsFullHADCoverage: """Lock the HAD section additions to llms-full.txt against deletion or framing drift. Phase 5 surfaces the agent-facing API contract for @@ -929,20 +954,14 @@ def test_llms_full_stacked_fit_documents_covariates(self): "does not document it." ) - def test_llms_full_stacked_fit_aggregate_line_documents_shim(self): - # M-024: the documented fit signature must carry the sentinel - # default + deprecation marker (the dCDH precedent's form), not - # the stale `aggregate: str = None` - no other pin covers this - # line, so it could silently go stale. + def test_llms_full_stacked_fit_block_has_no_fit_time_aggregate(self): + # M-024 (removed at 4.0, PR 3b): the documented fit signature carries + # no `aggregate` line any more - aggregation is post-fit only. section = self._stacked_section() fit_start = section.index("stacked.fit(") fit_block = section[fit_start : section.index("\n)", fit_start)] - agg_line = next( - line for line in fit_block.splitlines() if line.strip().startswith("aggregate") - ) - assert "NOT_SUPPLIED" in agg_line - assert "DEPRECATED (M-024)" in agg_line - assert "results.aggregate()" in agg_line + _assert_no_fit_time_aggregation_lines(fit_block, "StackedDiD") + assert "NOT_SUPPLIED" not in fit_block # balance= must be documented somewhere in the section (constructor param) assert "balance" in section @@ -1009,21 +1028,12 @@ def _efficient_section(self): nxt = text.index("\n### ", start + 1) return text[start:nxt] - def test_llms_full_efficient_fit_aggregate_line_documents_shim(self): - section = self._efficient_section() - fit_start = section.index("edid.fit(") + def test_llms_full_efficient_fit_block_has_no_fit_time_aggregate(self): + # M-023 / M-120 (removed at 4.0, PR 3b). + section = self._section("### EfficientDiD") + fit_start = section.index(".fit(") fit_block = section[fit_start : section.index("\n)", fit_start)] - agg_line = next( - line for line in fit_block.splitlines() if line.strip().startswith("aggregate") - ) - assert "NOT_SUPPLIED" in agg_line - assert "DEPRECATED (M-023)" in agg_line - assert "results.aggregate()" in agg_line - bal_line = next( - line for line in fit_block.splitlines() if line.strip().startswith("balance_e") - ) - assert "NOT_SUPPLIED" in bal_line - assert "DEPRECATED (M-120)" in bal_line + _assert_no_fit_time_aggregation_lines(fit_block, "EfficientDiD") def _section(self, header): text = get_llm_guide("full") @@ -1050,6 +1060,12 @@ def _assert_shim_lines(self, section, fit_call, agg_row, bal_row=None): assert "NOT_SUPPLIED" in bal_line assert f"DEPRECATED ({bal_row})" in bal_line + def _assert_no_shim_lines(self, section, fit_call): + fit_start = section.index(fit_call) + fit_block = section[fit_start : section.index("\n)", fit_start)] + _assert_no_fit_time_aggregation_lines(fit_block, fit_call) + assert "NOT_SUPPLIED" not in fit_block + def test_llms_full_imputation_fit_aggregate_line_documents_shim(self): # The M-021/M-118 twin of the EfficientDiD pin above - no other # test covers the ImputationDiD signature block's shim comments. @@ -1062,10 +1078,10 @@ def test_llms_full_continuous_fit_aggregate_line_documents_shim(self): # M-025: no balance_e twin exists - bal_row stays None. self._assert_shim_lines(self._section("### ContinuousDiD"), "cdid.fit(", "M-025") - def test_llms_full_had_fit_aggregate_line_documents_shim(self): - # M-027: the mode-selector shim (panel-shape inference successor); - # no balance_e twin exists - bal_row stays None. - self._assert_shim_lines(self._section("### HeterogeneousAdoptionDiD"), "had.fit(", "M-027") + def test_llms_full_had_fit_block_has_no_fit_time_aggregate(self): + # M-027 (removed at 4.0, PR 3b): the mode selector is gone from the + # documented signature; panel-shape inference is the successor. + self._assert_no_shim_lines(self._section("### HeterogeneousAdoptionDiD"), "had.fit(") def test_llms_full_had_examples_do_not_teach_mode_kwarg(self): # M-027/M-139 regression guard (local review R1 P2): outside the @@ -1090,7 +1106,6 @@ def test_llms_full_had_examples_do_not_teach_mode_kwarg(self): or "aggregate='event_study'" in line or 'aggregate="event_study"' in line ) - and "DEPRECATED" not in line ] assert offending == [], offending diff --git a/tests/test_had.py b/tests/test_had.py index abaa26ce5..d941233cf 100644 --- a/tests/test_had.py +++ b/tests/test_had.py @@ -22,8 +22,8 @@ all-NaN inference. 9. sklearn clone round-trip preserves raw ``design="auto"``; fit is idempotent. -10. Scaffolding (``aggregate="event_study"``, ``survey``, ``weights``) - raises ``NotImplementedError`` with phase pointers. +10. Scaffolding: the removed fit-time ``aggregate=`` keyword is a native + ``TypeError``; ``survey`` / ``weights`` positional slots are gone. 11. ``get_params()`` keys match ``__init__`` signature. 12. REGISTRY ticks tested indirectly via parity with the paper rules. """ @@ -565,39 +565,16 @@ def test_unbalanced_panel_raises(self): with pytest.raises(ValueError, match=r"[Uu]nbalanced|[Bb]alanced"): est.fit(panel, "outcome", "dose", "period", "unit") - def test_three_periods_supplied_overall_raises(self): - # M-027: a PLAIN 3-period fit now infers the event-study mode (the - # error->works behavior delta, pinned in test_aggregate_contract); - # the overall estimator's two-period requirement still binds on the - # supplied legacy path. + def test_fit_time_aggregate_kwarg_is_gone(self): + # 4.0: the mode follows the panel shape (M-027 inference; a plain + # 3-period fit selects the event study, pinned in + # test_aggregate_contract). The removed fit-time ``aggregate=`` + # keyword is a native TypeError - there is no supplied-mode path. d, dy = _dgp_continuous_at_zero(200, seed=0) - panel2 = _make_panel(d, dy) - panel3 = pd.concat([panel2, panel2.assign(period=3)]) - est = HeterogeneousAdoptionDiD() - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match=r"exactly two time periods"): - est.fit(panel3, "outcome", "dose", "period", "unit", aggregate="overall") - - def test_three_periods_with_first_treat_supplied_overall_raises(self): - # See test_three_periods_supplied_overall_raises: the plain-fit - # routing changed with M-027; the supplied legacy path keeps the - # overall estimator's two-period requirement. - d, dy = _dgp_continuous_at_zero(200, seed=0) - panel2 = _make_panel(d, dy) - panel3 = pd.concat([panel2, panel2.assign(period=3)]) - panel3["ft"] = 2 # arbitrary first_treat + panel = _make_panel(d, dy) est = HeterogeneousAdoptionDiD() - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match=r"exactly two time periods"): - est.fit( - panel3, - "outcome", - "dose", - "period", - "unit", - first_treat="ft", - aggregate="overall", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit(panel, "outcome", "dose", "period", "unit", aggregate="overall") def test_single_period_raises(self): d, _ = _dgp_continuous_at_zero(200, seed=0) @@ -900,38 +877,23 @@ def test_set_params_rollback_on_invalid_alpha(self): class TestScaffoldingRejections: - def test_aggregate_event_study_on_two_period_panel_raises(self): - """Event-study mode requires T > 2 (Phase 2b). Reaching it on a T=2 - panel now takes the deprecated supplied path (a plain T=2 fit infers - the overall mode, M-027); the shape error still binds there.""" + def test_aggregate_kwarg_removed_raises_type_error(self): + """4.0: the mode is inferred from the panel shape (a T=2 panel is the + overall estimator, T > 2 the event study); the fit-time + ``aggregate=`` keyword no longer exists, so supplying it (any value) + is a native TypeError.""" d, dy = _dgp_continuous_at_zero(200, seed=0) panel = _make_panel(d, dy) est = HeterogeneousAdoptionDiD() - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="more than two"): - est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - ) - - def test_aggregate_invalid_raises(self): - d, dy = _dgp_continuous_at_zero(200, seed=0) - panel = _make_panel(d, dy) - est = HeterogeneousAdoptionDiD() - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="Invalid aggregate"): - est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="garbage", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit( + panel, + "outcome", + "dose", + "period", + "unit", + aggregate="event_study", + ) def test_survey_design_bad_type_raises(self): """survey_design= must be a SurveyDesign-like object with a @@ -2544,40 +2506,39 @@ def test_continuous_at_zero_per_horizon_matches_phase_2a(self): assert np.isnan(overall_result.att) -class TestEventStudyAggregateMatrix: - """2x2 period/aggregate matrix: reciprocal rejections.""" +class TestEventStudyModeInference: + """The mode follows the panel shape: T=2 -> overall result, T>2 -> + event-study result; the fit-time ``aggregate=`` keyword is gone.""" - def test_T2_event_study_raises(self): + def test_T2_returns_overall_result(self): d = np.linspace(0.0, 1.0, 100) dy = 0.3 * d + 0.01 * np.random.default_rng(0).standard_normal(100) panel = _make_panel(d, dy) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="more than two"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + res = HeterogeneousAdoptionDiD(design="auto").fit( + panel, "outcome", "dose", "period", "unit" + ) + assert isinstance(res, HeterogeneousAdoptionDiDResults) + assert not isinstance(res, HeterogeneousAdoptionDiDEventStudyResults) - def test_T_gt_2_overall_raises(self): + def test_T_gt_2_returns_event_study_result(self): rng = np.random.default_rng(0) G = 100 d = rng.uniform(0.0, 1.0, G) d[0] = 0.0 panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="exactly two time periods"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="overall" - ) + res = HeterogeneousAdoptionDiD(design="auto").fit( + panel, "outcome", "dose", "period", "unit" + ) + assert isinstance(res, HeterogeneousAdoptionDiDEventStudyResults) - def test_invalid_aggregate_raises(self): + def test_aggregate_kwarg_is_type_error(self): d = np.linspace(0.0, 1.0, 100) dy = 0.3 * d panel = _make_panel(d, dy) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="Invalid aggregate"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="garbage" - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + HeterogeneousAdoptionDiD(design="auto").fit( + panel, "outcome", "dose", "period", "unit", aggregate="overall" + ) class TestEventStudyPlacebos: diff --git a/tests/test_had_dual_knob_deprecation.py b/tests/test_had_dual_knob_deprecation.py index 2f84afeb3..5b4240a84 100644 --- a/tests/test_had_dual_knob_deprecation.py +++ b/tests/test_had_dual_knob_deprecation.py @@ -467,7 +467,8 @@ def test_cband_is_keyword_only(self, two_period_panel): df = two_period_panel est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with pytest.raises(TypeError): - # positional through aggregate, then a positional cband -- now + # the removed `aggregate` slot and a positional cband -- fit() + # has 6 positional parameters (data..first_treat) and cband is # keyword-only, so the 8th positional argument is rejected. est.fit(df, "y", "d", "time", "unit", None, "overall", True) r = est.fit(df, "y", "d", "time", "unit", cband=True) @@ -494,7 +495,7 @@ def test_fit_rejects_pre_resolved_design_overall(self, two_period_panel): ) def test_fit_rejects_pre_resolved_design_event_study(self, event_study_continuous_panel): - """PR #376 R8 P1: same TypeError on aggregate='event_study'.""" + """PR #376 R8 P1: same TypeError on the multi-period (event-study) path.""" df = event_study_continuous_panel est = HeterogeneousAdoptionDiD(design="continuous_at_zero", n_bootstrap=99, seed=0) with pytest.raises(TypeError, match=r"`survey_design=` accepts a SurveyDesign"): @@ -629,7 +630,7 @@ def test_survey_design_kwarg_smoke(self, mass_point_panel): class TestHADFitEventStudySurveyDesign: - """PR #376 R2 P1: cover aggregate='event_study' + cband=True + survey_design=.""" + """PR #376 R2 P1: cover the multi-period (event-study) path + cband=True + survey_design=.""" def test_survey_design_kwarg_smoke(self, event_study_continuous_panel): df = event_study_continuous_panel @@ -651,8 +652,8 @@ def test_survey_design_kwarg_smoke(self, event_study_continuous_panel): class TestDidHadPretestWorkflowEventStudySurveyDesign: - """PR #376 R2 P1: cover did_had_pretest_workflow(aggregate='event_study', - survey_design=...).""" + """PR #376 R2 P1: cover did_had_pretest_workflow on a multi-period panel + (event-study battery) with survey_design=.""" def test_survey_design_kwarg_smoke(self, event_study_panel): df = event_study_panel diff --git a/tests/test_had_pretests.py b/tests/test_had_pretests.py index 0f278a6f2..f8a92fcf1 100644 --- a/tests/test_had_pretests.py +++ b/tests/test_had_pretests.py @@ -2333,10 +2333,9 @@ def test_overall_aggregate_unchanged(self): def test_overall_battery_rejects_multi_period_on_supplied_path(self): """Registry/docstring contract: the overall battery is two-period only. - A PLAIN multi-period workflow call now selects the event-study - battery (M-139 panel-shape inference); the two-period requirement - still binds when the legacy aggregate='overall' override is - supplied, so the registry text and the validator stay in lock-step. + A PLAIN multi-period workflow call selects the event-study battery + (M-139 panel-shape inference); since 4.0 there is no mode override - + the removed ``aggregate=`` keyword is a native TypeError. """ df = _make_multi_period_panel( G=40, @@ -2344,11 +2343,10 @@ def test_overall_battery_rejects_multi_period_on_supplied_path(self): first_treat_period=1999, seed=313, ) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match=r"exactly two time periods"): - did_had_pretest_workflow( - df, "y", "d", "period", "unit", n_bootstrap=199, seed=0, aggregate="overall" - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + did_had_pretest_workflow( + df, "y", "d", "period", "unit", n_bootstrap=199, seed=0, aggregate="overall" + ) def test_event_study_linear_dgp_all_pass(self): df = self._linear_panel(seed=101) @@ -2457,20 +2455,6 @@ def shifted_dose_fn(rng_, G): if report.qug.reject: assert report.verdict.startswith("support infimum rejected") - def test_invalid_aggregate_raises(self): - df = self._linear_panel(seed=102) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="aggregate must be one of"): - did_had_pretest_workflow( - df, - "y", - "d", - "period", - "unit", - aggregate="bogus", - n_bootstrap=199, - ) - def test_single_pre_period_yields_pretrends_skipped(self): """If t_pre_list has only the base pre-period, no earlier placebos exist -> pretrends_joint is None and verdict flags the skip.""" diff --git a/tests/test_honest_did.py b/tests/test_honest_did.py index cee1e4428..ea071429a 100644 --- a/tests/test_honest_did.py +++ b/tests/test_honest_did.py @@ -1289,7 +1289,7 @@ def test_event_study_vcov_computed(self): "period", "first_treat", ) - # Plain-fit invariant: no fit-time surface or vcov is stored. + # 4.0 invariant: no CS fit stores the fit-time surface or its vcov. assert cs_result.event_study_effects is None assert cs_result.event_study_vcov is None assert cs_result.event_study_vcov_index is None diff --git a/tests/test_methodology_callaway.py b/tests/test_methodology_callaway.py index 8f08cc569..864bfdc7c 100644 --- a/tests/test_methodology_callaway.py +++ b/tests/test_methodology_callaway.py @@ -261,7 +261,7 @@ def test_base_period_universal_includes_reference_period(self): first_treat="first_treat", ) - # A plain fit stores no event study; it is computed post-fit + # 4.0: the fit stores no event study; it is computed post-fit assert results.event_study_effects is None es = es_dict(results) @@ -1503,7 +1503,7 @@ def test_cband_crit_value_exceeds_pointwise(self, ci_params): time="period", first_treat="first_treat", ) - # A plain fit stores no band; the post-fit event study carries it + # 4.0: the fit stores no band; the post-fit event study carries it assert results.cband_crit_value is None es = results.aggregate("event_study") diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index e185ba34c..d466e012e 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -574,10 +574,10 @@ def _build_rowed_index(): **{f"{cls}.groups": _CS_COHORT for cls in _CS_GROUPS_CLASSES}, "GroupTimeEffect.group": _CS_COHORT, "HADPretestReport.aggregate": ( - "honest OUTPUT metadata, not the deprecated param (M-139 kills only " - "did_had_pretest_workflow's aggregate= INPUT): the field records " - "which pretest battery the workflow RAN, and the overall/event_study " - "modes survive 4.0 - only the routing param dies" + "honest OUTPUT metadata, not the removed param (M-139 removed only " + "did_had_pretest_workflow's aggregate= INPUT at 4.0): the field " + "records which pretest battery the workflow RAN, and the " + "overall/event_study modes survive - only the routing param died" ), "plot_group_effects[groups]": _CS_COHORT + " - cohort selector on the plotting surface", "DMLDiDResults.overall_att": ( @@ -1261,14 +1261,34 @@ def _token_family_code_refs(tok): # the docs/guides attr lane. Each entry below was verified clean of # stale fit-time `aggregate=` teaching before allowlisting (the # migrate-first rule; stale sites were migrated in the same diff that - # added these entries). Dated phase-closure records in the paper-review - # doc describe the API as-shipped at closure time (the released-record - # convention) and are not regenerated for a deprecation sweep. + # added these entries - the CallawaySantAnna / EfficientDiD / StackedDiD / + # dCDH / HAD teaching with the 4.0 PR 3b removal of their fit-time + # keyword). Dated phase-closure records in the paper-review doc describe + # the API as-shipped at closure time (the released-record convention) and + # are not regenerated for a deprecation sweep. ("aggregate", "diff_diff/guides/llms-autonomous.txt"): ( "canonical post-fit results.aggregate() vocabulary; the HAD " "mode-kwarg teachings were migrated to panel-shape wording with " "M-027/M-139" ), + ("aggregate", "diff_diff/guides/llms.txt"): ( + "canonical post-fit results.aggregate() vocabulary; the fit-time " + "aggregate= teaching for CS/EfficientDiD/StackedDiD/dCDH/HAD was " + "removed with the 4.0 PR 3b sweep (M-020/M-023/M-024/M-026/M-027, " + "M-117/M-120, M-139); the ImputationDiD/TwoStageDiD/ContinuousDiD " + "clauses describe their still-deprecated keyword until PR 3c, and " + "TripleDifference's canonical staggered-mode keyword survives " + "(M-140/M-141)" + ), + ("aggregate", "diff_diff/guides/llms-full.txt"): ( + "canonical post-fit results.aggregate() vocabulary and the " + "CS/EfficientDiD/StackedDiD/dCDH/HAD signature blocks WITHOUT the " + "removed fit-time lines (4.0 PR 3b sweep; pinned by " + "tests/test_guides.py's *_fit_block_has_no_fit_time_aggregate " + "tests); the ImputationDiD/TwoStageDiD/ContinuousDiD blocks still " + "carry their DEPRECATED shim lines until PR 3c, and TripleDifference's " + "fit-time aggregate= survives (M-140/M-141)" + ), ("aggregate", "docs/methodology/REGISTRY.md"): ( "the aggregate-postfit register Notes (M-020..M-027) teach the " "canonical successor method; HAD-section fit-time mode mentions " @@ -1282,9 +1302,9 @@ def _token_family_code_refs(tok): "consumes" ), ("aggregate", "docs/methodology/REPORTING.md"): ( - "canonical post-fit aggregate() mention only (zero fit-time " - "kwarg sites; the fit-time-population clause was reworded with " - "M-027)" + "canonical post-fit aggregate() mention only (zero fit-time kwarg " + "sites; the CS deprecated-route clause was removed with the 4.0 PR 3b " + "sweep and the fit-time-population clause was reworded with M-027)" ), ("aggregate", "docs/methodology/papers/dechaisemartin-2026-review.md"): ( "dated phase-closure records describing the API as-shipped at " diff --git a/tests/test_stacked_did.py b/tests/test_stacked_did.py index 017ffbc0f..07f349a56 100644 --- a/tests/test_stacked_did.py +++ b/tests/test_stacked_did.py @@ -105,33 +105,33 @@ def test_event_study(self, staggered_data): if h in results.event_study_effects: assert results.event_study_effects[h]["n_obs"] > 0 - def test_group_aggregate_raises(self, staggered_data): - """aggregate='group' warns (M-024 shim) then raises ValueError.""" + def test_fit_time_aggregate_kwarg_removed(self, staggered_data): + """4.0: the fit-time ``aggregate=`` keyword is gone (native TypeError); + the event study is always computed.""" est = StackedDiD(kappa_pre=2, kappa_post=2) - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with pytest.raises(ValueError, match="group.*not supported"): - est.fit( - staggered_data, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="group", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit( + staggered_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + aggregate="group", + ) - def test_all_aggregate_raises(self, staggered_data): - """aggregate='all' warns (M-024 shim) then raises ValueError.""" + def test_group_aggregation_unsupported(self, staggered_data): + """StackedDiD exposes no group aggregation: the post-fit + ``aggregate("group")`` raises the mixin's unsupported-type error.""" est = StackedDiD(kappa_pre=2, kappa_post=2) - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with pytest.raises(ValueError, match="all.*not supported"): - est.fit( - staggered_data, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="all", - ) + results = est.fit( + staggered_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + ) + with pytest.raises(ValueError, match="Unsupported aggregation type"): + results.aggregate("group") def test_simple_att(self, staggered_data): """Plain fit: overall ATT plus the always-computed surface (M-024).""" @@ -898,18 +898,27 @@ def test_invalid_cluster(self): StackedDiD(cluster="invalid") def test_invalid_aggregate(self, staggered_data): - """Warns (M-024 shim) then raises on invalid aggregate parameter.""" + """The fit-time ``aggregate=`` keyword is gone (4.0): any value is a + native TypeError; an unknown post-fit aggregation type raises.""" est = StackedDiD() - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with pytest.raises(ValueError, match="aggregate"): - est.fit( - staggered_data, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="invalid", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit( + staggered_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + aggregate="invalid", + ) + results = est.fit( + staggered_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + ) + with pytest.raises(ValueError, match="Unsupported aggregation type"): + results.aggregate("invalid") def test_population_required_for_population_weighting(self, staggered_data): """Raises when population col not specified with weighting='population'.""" diff --git a/tests/test_staggered.py b/tests/test_staggered.py index 50c4cd4fd..9a88f8171 100644 --- a/tests/test_staggered.py +++ b/tests/test_staggered.py @@ -131,7 +131,7 @@ def test_event_study_aggregation(self): first_treat="first_treat", ) - # A plain fit does not populate the fit-time surface; aggregation is post-fit + # 4.0: fit never populates the fit-time surface; aggregation is post-fit assert results.event_study_effects is None assert results.group_effects is None es = results.aggregate("event_study") @@ -337,7 +337,7 @@ def test_event_study_not_computed_error(self): data, outcome="outcome", unit="unit", time="time", first_treat="first_treat" ) - with pytest.raises(ValueError, match="Event study effects not computed"): + with pytest.raises(ValueError, match="not stored on 4.0 fits"): results.to_dataframe(level="event_study") diff --git a/tests/test_survey_phase7a.py b/tests/test_survey_phase7a.py index 782e44fcf..2df4e6a9b 100644 --- a/tests/test_survey_phase7a.py +++ b/tests/test_survey_phase7a.py @@ -83,7 +83,7 @@ def test_event_study(self, staggered_data_with_covariates, survey_weights_only, covariates=["x1"], survey_design=survey_weights_only, ) - # A plain CS fit leaves the legacy field None + # 4.0: the fit-time surface is gone - CS leaves the legacy field None # on every fit and the post-fit container carries the event study. assert result.event_study_effects is None es = result.aggregate("event_study") diff --git a/tests/test_v4_rename_mechanical.py b/tests/test_v4_rename_mechanical.py index f939c3fe6..9bc82f5e9 100644 --- a/tests/test_v4_rename_mechanical.py +++ b/tests/test_v4_rename_mechanical.py @@ -35,6 +35,7 @@ deprecated_kwarg_message, require_arg, resolve_renamed_kwarg, + warn_deprecated_kwarg, ) @@ -76,6 +77,14 @@ def test_message_template(self): "Cls.fit(old=) is deprecated and will be removed in 4.0; " "use new= instead." ) + def test_warn_deprecated_kwarg_emits_future_warning(self): + # Retained 5.0-window helper (spec section 8 rule 11): its last library + # caller (dCDH fit(aggregate=), M-026) was removed at 4.0, so this pin + # keeps the helper itself under test. + with pytest.warns(FutureWarning, match=r"Cls\.fit\(old=\) is deprecated") as rec: + warn_deprecated_kwarg("Cls.fit", "old", "use new= instead", stacklevel=2) + assert str(rec[0].message) == deprecated_kwarg_message("Cls.fit", "old", "use new= instead") + def test_resolve_neither_returns_default(self): with warnings.catch_warnings(): warnings.simplefilter("error") diff --git a/tests/test_variance_conventions.py b/tests/test_variance_conventions.py index 1062243cd..cdc690470 100644 --- a/tests/test_variance_conventions.py +++ b/tests/test_variance_conventions.py @@ -386,7 +386,7 @@ def snapshot(self): dict( key="imputation_pretrends_event_study", # The pretrends lead regression runs on the post-fit event-study - # route (no fit-time aggregate=), so the row spies fit + aggregate. + # route (4.0: no fit-time aggregate=), so the row spies fit + aggregate. fit=lambda df: diff_diff.ImputationDiD(pretrends=True) .fit(df, outcome="y", unit="unit", time="time", first_treat="first_treat") .aggregate("event_study"), diff --git a/tests/test_visualization.py b/tests/test_visualization.py index c49f1e64c..7d0c97de3 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -99,7 +99,7 @@ def multi_period_results(self): @pytest.fixture def cs_fit(self): - """A plain CallawaySantAnna fit (no fit-time event-study surface).""" + """A plain CallawaySantAnna fit (4.0: no fit-time event-study surface).""" data = generate_staggered_data() cs = CallawaySantAnna() return cs.fit( @@ -130,7 +130,7 @@ def test_plot_from_cs_results(self, cs_results): assert ax is not None def test_cs_results_object_has_no_fit_time_surface(self, cs_fit): - """Plain-fit invariant: a CS results object carries no event-study surface. + """4.0 invariant: a CS results object carries no event-study surface. Every CallawaySantAnna fit leaves the legacy field None; plotting the results object itself fails closed and points at the container. @@ -838,7 +838,7 @@ def test_pt_post_anticipation_reference_shifts(self): time="period", first_treat="first_treat", ) - # A plain EfficientDiD fit stores no fit-time surface, so the MEMBERSHIP-GATED + # 4.0: EfficientDiD stores no fit-time surface, so the MEMBERSHIP-GATED # property is None on the results object itself ... assert res.event_study_effects is None assert res.reference_period is None