diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f29b5c8..0351f64c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **HeterogeneousAdoptionDiD post-fit `aggregate()` + panel-shape mode + inference, and the per-level bootstrap-gate convergence** (v4 program 2(b) + PR-4; ledger rows [M-027] + new row [M-139], amendments to [M-020]..[M-023], + [M-092], [M-093]). `HeterogeneousAdoptionDiD.fit(aggregate=)` and + `did_had_pretest_workflow(aggregate=)` are deprecated (`FutureWarning`; + removed in 4.0). Unlike every prior adopter the param was a MODE SELECTOR + over mutually exclusive panel shapes, so the successor is inference: both + surfaces now select the mode from the panel (two distinct periods -> the + overall single-period WAS estimator / pretest battery; more -> the + event-study ones), and a plain multi-period `fit()` - which previously + raised the two-period shape error - now just works (no numbers change on + any previously-working call; supplied legacy values warn and run the + legacy routing unchanged, invalid values still raise). Post-fit + `aggregate()` lands on both results classes as PURE VIEWS with per-class + supported subsets and NO retained kit (results unpickled from any release + aggregate identically): `aggregate('simple')` on overall fits is a one-row + bit-exact relay whose `target` carries the estimand label + (`WAS`/`WAS_d_lower`), with `n = n_obs` contributing units and + provenance-exact df; `aggregate('event_study')` on event-study fits is a + `build_event_study_surface` passthrough (cband fields included). The + `_from_had` adapter's `n_kind` is corrected `"obs"` -> `"units"` + (`n_obs_per_horizon` counts units); `EventStudyResults` gains an + `estimand` provenance field plus a per-row `estimand` column in the + pinned `to_dataframe()` schema (`"att"` for every ATT producer, the + WAS-family label for HAD, relayed from `target_parameter`) so the + container heading, `to_dict()` serialization, and detached frames never + mislabel the estimand; and `AggregationResult.summary()` now + renders the target column + neutral `estimate` heading whenever the single + distinct target is not `"att"` (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 are byte-stable. HAD + event-study containers stay outside `compute_honest_did` / + `compute_pretrends_power` - DEFERRED, not by-design: the coefficients ARE + reference-normalized against the F-1 anchor, but the anchor row is omitted + (identically zero and the WAS is not identified there) and no joint + cross-horizon covariance exists; both terminal TypeErrors say so. + **Per-level bootstrap-gate convergence:** CallawaySantAnna, EfficientDiD, + ImputationDiD and TwoStageDiD previously failed closed on EVERY + `aggregate()` level of a bootstrapped fit; their `aggregate('simple')` is + a bit-exact relay of the stored overall quintet (percentile se/p/CI beside + the finite `safe_inference` t), so it now dispatches before the bootstrap + gate and stays available - with a NaN df column, because no df governs + percentile inference - while the recompute levels keep failing closed + (this supersedes the uniform-conservatism decision recorded with + [M-021]..[M-023]; the normative per-level rule lives in v4-design + section 6). The `HADPretestReport.aggregate` FIELD survives as output + metadata recording which battery ran; only the params die. - **ContinuousDiD post-fit `aggregate()` - a MIXED view/recompute adopter** (v4 program 2(b) PR-3c; ledger row [M-025]). `ContinuousDiD.fit(aggregate=)` is deprecated (`FutureWarning`; removed in 4.0, and the no-underscore @@ -85,9 +132,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 copy of the working frame (O(n_obs); replicate designs additionally retain the O(n_obs x R) replicate matrix). A `store_kit` opt-out is tracked in DEFERRED.md. - - Bootstrapped fits fail closed for ALL aggregate() levels including - 'simple' (uniform CS/EfficientDiD parity; replay wiring is tracked - in TODO.md); a fit whose bootstrap FAILED aggregates normally. + - Bootstrapped fits: `aggregate('simple')` relays the stored quintet + verbatim with a NaN df column, while the recompute levels fail + closed (replay wiring is tracked in TODO.md; the per-level policy + converged with [M-027] later in the same cycle); a fit whose + bootstrap FAILED aggregates normally. TwoStage post-fit event-study containers reproduce the M-092 contract exactly (joint GMM vcov + index + df on analytical fits; vcov=None with the replayed df on replicate fits). @@ -142,11 +191,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 successor fails closed on unknown types (behavior improvement). `EfficientDiD`'s balance rule is the anchor-horizon rule - the same rule CallawaySantAnna uses. - - Bootstrapped fits (`n_bootstrap > 0`) fail closed on `aggregate()` - at every level (CS parity); the deprecated fit-time aggregation - remains the supported bootstrapped route, and its group rows now - clear the analytical `df_used` provenance under the bootstrap - override. Exact post-fit bootstrap replay is a tracked TODO row. + - Bootstrapped fits (`n_bootstrap > 0`): `aggregate('simple')` relays + the stored quintet verbatim with a NaN df column, while the + recompute levels fail closed (the per-level policy converged with + [M-027] later in the same cycle); the deprecated fit-time + aggregation remains the supported bootstrapped route for those + levels, and its group rows still clear the analytical `df_used` + provenance under the bootstrap override. Exact post-fit bootstrap + replay is a tracked TODO row. - The aggregation methods moved verbatim to the new `diff_diff/efficient_did_aggregation.py` (importable by both the estimator and the results module); `efficient_did.py` drops below @@ -286,8 +338,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 delta/no-scalar-count) and a `df` column resolved from the ACTUAL inference path (the `L_max>=2` delta keeps its analytical survey df even under bootstrap, per the REGISTRY delta-SE note). Because - nothing is recomputed, bootstrap fits are permitted - unlike - CallawaySantAnna's kit-based `aggregate()`, which stays fail-closed. + nothing is recomputed, bootstrap fits are permitted (since [M-027] + the kit-based adopters' `'simple'` relays are permitted on + bootstrapped fits too - only their recompute levels stay closed). - Consumer hardening that landed with the wave: `plot_event_study` renders MULTI-reference containers via row-aligned hollow anchors (never dropped, never filled estimates; explicit `reference_period=` @@ -760,9 +813,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and forms no cross-cohort mass — reporting one would be fabricated. Fail-closed elsewhere too: `aggregate("calendar")` raises (CS has no calendar aggregator; the DEFERRED row stands), a non-`None` `weights` - raises, and `aggregate()` on a **bootstrapped** fit raises rather than - substituting analytical inference for percentile-bootstrap statistics — - bootstrap replay is tracked in `TODO.md`. + raises, and the recompute levels of `aggregate()` on a **bootstrapped** + fit raise rather than substituting analytical inference for + percentile-bootstrap statistics (`aggregate('simple')` relays the stored + bootstrap inference with a NaN df column since [M-027]'s per-level + convergence) — bootstrap replay is tracked in `TODO.md`. `AggregationResult.df` is per-row provenance: the degrees of freedom that actually produced that row's stored p-value and interval, read from the carrier the fit used (`survey_metadata.df_survey` on explicit survey diff --git a/METHODOLOGY_REVIEW.md b/METHODOLOGY_REVIEW.md index 4381399c..5dde4997 100644 --- a/METHODOLOGY_REVIEW.md +++ b/METHODOLOGY_REVIEW.md @@ -856,10 +856,10 @@ These three are feature deferrals (paper-supported extensions that the library h **Deviations from the paper / from R / library extensions:** 1. **Equal-weighting on the continuous path** (paper does not prescribe a unit-weighting scheme; library uses per-unit `w_g = 1` matching `_nprobust_port.lprobust`'s default, NOT cell-size weights). Locked in `tests/test_methodology_had.py::TestHADDeviations::test_equal_weighting_is_per_row_not_per_dose_cell` (probes the deviation via selective low-dose-region replication on a nonlinear DGP: per-row equal weighting predicts the att shifts; cell-size weighting predicts invariance). -2. **Sup-t bootstrap gating** — runs only when `aggregate="event_study"` AND `(weights= or survey_design= supplied)` AND `cband=True`. Unweighted event-study bit-exactly preserves pre-Phase 4.5 B output. Locked in `TestHADDeviations::test_sup_t_bootstrap_skipped_*`. +2. **Sup-t bootstrap gating** — runs only in the event-study mode (multi-period panels; the deprecated `aggregate=` override maps to it, M-027) AND `(weights= or survey_design= supplied)` AND `cband=True`. Unweighted event-study bit-exactly preserves pre-Phase 4.5 B output. Locked in `TestHADDeviations::test_sup_t_bootstrap_skipped_*`. 3. **Pierce-Schott Figure 2 replication waived** — R parity at `atol=1e-8` is a stronger anchor; paper Section 5.2 self-acknowledges NP estimators are too noisy on LBD-restricted PNTR data. See REGISTRY Deviations § "Pierce-Schott (2016) Figure 2 replication harness deferred" for the full scope-caveat statement. 4. **Table 1 coverage-rate reproduction waived** — same R-parity-is-stronger rationale; R parity locks point estimate + SE + CI bounds bit-exactly, coverage-rate MC would re-verify the CCF asymptotic coverage already pinned. Paper Table 1 (89% / 93% / 95% under-coverage at G=100 / 500 / 2500) documents the asymptotic gap that BOTH R and Python inherit. -5. **Staggered-timing fail-closed `ValueError`** at `diff_diff/had.py:1511` (paper prescribes "Warn"; library raises). Library extension toward stricter safety — `UserWarning` would let the silent-misuse bug class through. Locked in `TestHADDeviations::test_staggered_timing_fail_closed_value_error`. +5. **Staggered-timing fail-closed `ValueError`** at `diff_diff/had.py:1624` (paper prescribes "Warn"; library raises). Library extension toward stricter safety — `UserWarning` would let the silent-misuse bug class through. Locked in `TestHADDeviations::test_staggered_timing_fail_closed_value_error`. 6. **Eq. 18 linear-trend-detrended joint Stute SHIPPED** (PR #389) and R-parity-locked against `DIDHAD::did_had(..., trends_lin=TRUE)` v2.0.0 in `tests/test_did_had_parity.py` (3 DGPs × 5 method combos at `atol=1e-8`). The `tests/test_methodology_had.py::TestHADJointStute` walkthrough deliberately covers only the un-detrended mean-independence and linearity variants (no coverage duplication with the R-parity surface). The Pierce-Schott (2016) NUMERICAL replication against the published p=0.51 anchor on the LBD-restricted PNTR panel is what's waived (Deviations Note #3). **Outstanding Concerns:** @@ -1468,7 +1468,7 @@ Goldens at `benchmarks/data/r_conleyreg_conley_golden.json`; generator at `bench - **Survey + non-HC1 vcov** — HC2 / HC2-BM / classical **explicitly raise `NotImplementedError`** under `survey_design=` (a fail-closed guard: the survey TSL / replicate-refit variance would otherwise silently discard the requested sandwich family): `stacked_did.py:428`, `sun_abraham.py:751`, `wooldridge.py:702`, `twfe.py:252`. - **Survey + user `cluster=`** — **explicitly raises** (a fail-closed guard, not a silent drop): the survey TSL / replicate-refit variance would otherwise ignore `cluster=`, so the combination is rejected at construction: `efficient_did.py:518`, `staggered.py:1719`, `imputation.py:314`, `two_stage.py:1439`, `triple_diff.py:674`. - **SyntheticControl** — no survey support yet (`synthetic_control.py:335`). -- **HAD-specific** — `trends_lin=True` + survey (`had.py:3067`); QUG pretest + survey (extreme-order statistic not smooth in the empirical CDF, `had_pretests.py:1455`); `lonely_psu='adjust'` + singleton strata on the sup-t / Stute bootstrap. +- **HAD-specific** — `trends_lin=True` + survey (`had.py:3192`); QUG pretest + survey (extreme-order statistic not smooth in the empirical CDF, `had_pretests.py:1455`); `lonely_psu='adjust'` + singleton strata on the sup-t / Stute bootstrap. - **HonestDiD M>0 smoothness** survey FLCI uses asymptotic normal only (`df_survey=0`→NaN); tracked in the HonestDiD section. --- diff --git a/TODO.md b/TODO.md index 9cf78393..a80b4a1d 100644 --- a/TODO.md +++ b/TODO.md @@ -21,11 +21,11 @@ Related tracking surfaces: | Issue | Location | Origin | Effort | Priority | |-------|----------|--------|--------|----------| -| EfficientDiD `aggregate()` on bootstrapped fits fails closed (M-023); wiring `BootstrapReplaySpec` (or retaining the n_bootstrap x n_gt draw matrix materialized at fit) would enable exact post-fit replay of percentile inference | `diff_diff/efficient_did_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3a | Mid | Low | -| ImputationDiD/TwoStageDiD `aggregate()` on bootstrapped fits fails closed (M-021/M-022); ImputationDiD's per-target psi machinery makes seeded replay tractable (the panel-backed kit retains everything the psi precompute reads), TwoStageDiD's per-level GMM scores are function-locals and would need retention | `diff_diff/imputation_results.py`, `diff_diff/two_stage_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3b | Mid | Low | +| EfficientDiD `aggregate()` recompute levels (event_study/group) on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence); wiring `BootstrapReplaySpec` (or retaining the n_bootstrap x n_gt draw matrix materialized at fit) would enable exact post-fit replay of percentile inference | `diff_diff/efficient_did_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3a | Mid | Low | +| ImputationDiD/TwoStageDiD `aggregate()` recompute levels on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence; M-021/M-022); ImputationDiD's per-target psi machinery makes seeded replay tractable (the panel-backed kit retains everything the psi precompute reads), TwoStageDiD's per-level GMM scores are function-locals and would need retention | `diff_diff/imputation_results.py`, `diff_diff/two_stage_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3b | Mid | Low | | ContinuousDiD `aggregate('event_study')` on bootstrapped fits fails closed (M-025); a seeded post-fit bootstrap-ES replay is tractable - the multiplier draws are seeded (`np.random.default_rng(self.seed)`) - but needs the FULL per-cell `_bootstrap_info` (bread/ee_treated/Psi_eval/dPsi_*/beta_pred) the pruned kit deliberately drops, so shipping it means a kit-payload change with its own memory contract | `diff_diff/continuous_did_aggregation.py`, `diff_diff/continuous_did_results.py` | 2(b) PR-3c | Mid | Low | | diagnostic_report's ES-gated checks read the raw `event_study_effects` field, which post-fit `results.aggregate()` never populates - their remediation strings steer users to the deprecated fit-time kwarg (qualified "deprecated but functional until 4.0" since 2(b) PR-3b); teach the checks to consume a post-fit container (or recompute via the kit) before 4.0 removes the kwarg | `diff_diff/diagnostic_report.py` | 2(b) PR-3b | Mid | Medium | -| EfficientDiD, ImputationDiD and ContinuousDiD are the outstanding M-092 event-study df-provenance holes: the container's per-row df is all-NaN even on survey fits where a finite `_survey_df` governed the p-values (the container-level scalar `df_survey` IS exposed - the hole is the PER-ROW column only; no event_study_df/df_inference field; pre-existing, NOT a regression of the M-023 PR - today's builder output is identical). The kits now retain the scalar (ImputationDiD's since 2(b) PR-3b, ContinuousDiD's since 2(b) PR-3c - same shape: scalar `df_survey` exposed, per-row column all-NaN, identical to each fit-time surface); threading it into the per-row channel is a contained follow-up | `diff_diff/efficient_did_results.py`, `diff_diff/imputation_results.py`, `diff_diff/continuous_did_results.py`, `diff_diff/results_base.py` | 2(b) PR-3a | Quick | Low | +| EfficientDiD, ImputationDiD, ContinuousDiD and HeterogeneousAdoptionDiD are the outstanding M-092 event-study df-provenance holes: the container's per-row df is all-NaN even on survey fits where a finite `_survey_df` governed the p-values (the container-level scalar `df_survey` IS exposed - the hole is the PER-ROW column only; no event_study_df/df_inference field; pre-existing, NOT a regression of the M-023 PR - today's builder output is identical). The kits now retain the scalar (ImputationDiD's since 2(b) PR-3b, ContinuousDiD's since 2(b) PR-3c - same shape: scalar `df_survey` exposed, per-row column all-NaN, identical to each fit-time surface); threading it into the per-row channel is a contained follow-up | `diff_diff/efficient_did_results.py`, `diff_diff/imputation_results.py`, `diff_diff/continuous_did_results.py`, `diff_diff/results_base.py` | 2(b) PR-3a | Quick | Low | | practitioner `step_name="heterogeneity"` producer-side collisions: three OTHER estimators' advice steps reuse the key with non-heterogeneity labels (`:975` ContinuousDiD dose-response, `:1022` Triple placebo-group, `:1413` LPDiD WAS arrays), so DiagnosticReport's heterogeneity completion silently drops that unrelated advice from `next_steps` via `_filter_steps` - the same latent collision fixed for StackedDiD in M-024 (renamed to `sub_experiment_balance`). Renaming these changes those estimators' report output; audit + rename with per-estimator pins. | `diff_diff/practitioner.py` | 2(b) PR-2 review R9 | Quick | Low | | PreTrendsPower `violation='linear'` on CS `base_period='varying'` input targets the wrong alternative: `δ_pre = M · \|t\|` assumes level coefficients against a common reference, but varying-base pre-treatment effects are consecutive-period comparisons (constant increments under a linear trend). Both CS-sourced routes now WARN (REGISTRY PreTrendsPower Note), and universal-base GAPPED grids fail closed via the `reference_event_times` common-reference guard; what remains is the varying-base resolution - either transforming the violation vector through each coefficient's actual base mapping (needs per-horizon base provenance) or requiring `base_period='universal'` for the linear benchmark - a per-estimator methodology decision with a hand-calculated linear-violation gate | `diff_diff/pretrends.py` | 2(b) PR-1 R5 | Mid | Medium | | `run_placebo_test`/`run_all_placebo_tests` expose ONE overloaded `time` param (calendar for timing/group tests, 0/1 post dummy forwarded into the M-137/M-138 callees - works only because a two-period 0/1 calendar column is both); redesign the wrapper signatures (dual `time=`/`post=` params or a split) so each callee gets an honestly-named argument | `diff_diff/diagnostics.py` | 2(c)-ii PR-B | Mid | Medium | @@ -40,7 +40,7 @@ Related tracking surfaces: | `WooldridgeDiD` DROPS the observations of a cohort with no supported pre-period before `g - anticipation` ([M-123]) rather than identifying it. Excluding the rows is correct given `g-1` normalization -- leaving them in silently loads the cohort's effect onto the time FE -- but dropping a cohort a user supplied is a lossy last resort. **Route (b) is now SETTLED NEGATIVELY and is not the answer:** the paper's no-never-treated last-cohort normalization shipped (W2025 Sec 5.4, per-period comparison support), and it does NOT identify these cohorts -- `wooldridge-2025-review.md:477` is explicit that in the final period the last cohort's ATT is unidentified, and the implementation still excludes any cohort whose reference is `None`. **Route (a) remains open:** an explicit user-supplied reference period per cohort -- W2025 Section 6.1 says any pre-treatment period may serve and the pre-trend `t`-test is invariant to the choice, so a cohort with ANY supported pre-period is a candidate even when `g-1` is missing. If route (a) also fails to identify the cohort, convert this row into a REGISTRY Note recording exclusion as the deliberate final answer. | `diff_diff/wooldridge.py`, `docs/methodology/REGISTRY.md` | #724 | Heavy | Medium | | `WooldridgeDiD` REFUSES a panel whose units split into disconnected support groups within a cohort, rather than estimating what IS identified. The connectivity guard (REGISTRY *within-cohort support connectivity*) correctly detects that a closed component's cells are collinear with the unit FE — previously QR dropped one silently and the overall ATT averaged an incomplete set (issue #724's failure mode via unit support). **Refusing is the safe answer, not the complete one.** The connected component containing the reference is still fully identified, so the estimable resolution is either (a) estimate the connected component and report the disconnected units as excluded, with the estimand restated (a sub-population of units, so it needs a REGISTRY definition and interacts with the survey-domain row above), or (b) per-component references, if a component with its own pre-period can carry its own normalization — needs a methodology decision, since components then are not comparable on one baseline. Gate with the split-support fixture in `TestWithinCohortSupportConnectivity`. | `diff_diff/wooldridge.py` | #724-codex-R7 | Heavy | Medium | | `WooldridgeDiD` fully resolves the `SurveyDesign` TWICE on every supported survey fit. The pre-exclusion validation pass (added so invalid metadata cannot hide in rows that cohort exclusion deletes) calls `survey_design.resolve(sample)`, and each fitter then calls `_resolve_survey_for_wooldridge` -> `_resolve_survey_for_fit` on the same frame, repeating weight normalization, strata/PSU/FPC validation and design-array construction. Any fit that REACHES the second resolve has an unchanged sample (survey + unidentified-cohort exclusion raises first), so the first result is reusable: capture the `_resolve_survey_for_fit` 4-tuple early and thread it into the three fitters as an optional `pre_resolved`. **Caveat that makes this non-trivial:** `sample = sample.reset_index(drop=True)` runs BETWEEN the two calls, so the reused object must be verified index-independent (resolution extracts positional numpy arrays, but `_inject_cluster_as_psu` and the metadata recompute need checking), and the early call must stop suppressing warnings or the user loses the weight-normalization notice. Gate with a survey fit asserting one normalization warning and byte-identical SEs. | `diff_diff/wooldridge.py` | #724-codex-R10 | Mid | Low | -| Bootstrap re-aggregation for `CallawaySantAnnaResults.aggregate()` — a bootstrapped fit currently RAISES rather than substituting analytical inference for percentile-bootstrap statistics. The value-bound `BootstrapReplaySpec` (bit-identical replay, picklable, immune to post-fit `set_params`) is already in-tree and spike-verified; wiring it needs the per-`(g,t)` / per-event-time draw retention plus `assert_allclose` parity tests against the fit-time bootstrap numbers (NOT bit-identity — the fused GEMM's column count differs post-fit, ~1 ULP reassociation). | `diff_diff/aggregation.py`, `diff_diff/staggered_results.py` | #726 | Mid | Medium | +| Bootstrap re-aggregation for `CallawaySantAnnaResults.aggregate()` recompute levels — a bootstrapped fit's event_study/group currently RAISE ('simple' relays since the M-027 per-level convergence) rather than substituting analytical inference for percentile-bootstrap statistics. The value-bound `BootstrapReplaySpec` (bit-identical replay, picklable, immune to post-fit `set_params`) is already in-tree and spike-verified; wiring it needs the per-`(g,t)` / per-event-time draw retention plus `assert_allclose` parity tests against the fit-time bootstrap numbers (NOT bit-identity — the fused GEMM's column count differs post-fit, ~1 ULP reassociation). | `diff_diff/aggregation.py`, `diff_diff/staggered_results.py` | #726 | Mid | Medium | | Consolidate the inference-df precedence duplicated across `honest_did.py` (3 copies at ~L655/L836/L1004) onto the shared `resolve_inference_df()` helper added in `diff_diff/aggregation.py`. The copies are correct today; the risk is drift if the survey/replicate precedence changes in one place only. (Adjacent but distinct from PR C's `utils.resolve_tail_df`: that is the FIT-TIME `df_convention` fallback resolver, this is a post-fit results READER.) | `diff_diff/honest_did.py` | #726 | Quick | Low | | `ContinuousDiD` CGBS-2024 remaining extensions (earlier phases — `covariates=` reg/dr, `treatment_type="discrete"`, single-cohort `control_group="lowest_dose"` with estimand `ATT(d)−ATT(d_L)` — are already supported; see REGISTRY Note #7). Remaining (all deferred `NotImplementedError`, documented): `estimation_method="ipw"` on the dose curve (scalar-adjustment / degenerate); `covariates=` × `survey_design=` (weighted OR + weighted nuisance IF); multi-cohort **heterogeneous-support** discrete aggregation (support-aware: average each dose only over the cohorts that observe it); **multi-cohort `lowest_dose`** (within-cohort `d_L` reference + support-aware cross-cohort aggregation); and **`covariates=` × `lowest_dose`** (conditional-PT-relative-to-`d_L` estimand). Single-cohort / 2-period / shared-support multi-cohort are supported. | `continuous_did.py` | CGBS-2024 | Heavy | Low | | `WooldridgeDiD` does not apply the W2025 Sec 5.4 `D_{G_max} x X` covariate normalization, and three sibling covariate rank deficiencies are pre-existing. Measured with the period range pinned and only the never-treated units toggled: (1) time-invariant `exovar` is absorbed by the unit FE, 4 of 26 columns, IDENTICALLY with and without never-treated units; (2) `xgvar`'s cell x covariate block, 19 of 41, identical on both panels; (3) `xtvar` under `demean_covariates=False` does exhibit the `sum_g D_g x = x` dependency that the default demeaning removes; (4) the newly-reachable case -- time-VARYING data passed through `exovar`, which its own docstring reserves for time-invariant covariates -- where the paper's `dT_i` rule would give a deterministic `D_{G_max} x X` drop instead of QR's arbitrary pick (coefficients unaffected, `1.35e-14`; `rank_deficient_action="error"` raises). REGISTRY's narrowed Sec 5.4 note cross-references this row. **Trap for whoever takes it:** `xtvar` under the DEFAULT `demean_covariates=True` is FULL RANK -- the raw block carries demeaned values while `D_g x X` carries raw ones -- and forcing the drop there moves `overall_att` 1.11903 -> 1.46269. Pinned as-is by `TestComparisonSupportFiltering::test_cells_derived_groups_did_not_leak_into_the_design`. | `diff_diff/wooldridge.py` | #729-followup | Heavy | Medium | @@ -65,6 +65,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m | Issue | Location | Origin | Effort | Priority | |-------|----------|--------|--------|----------| +| M-020-era CS fit-time `aggregate=` teachings persist in troubleshooting.rst (:215/:241/:244) and choosing_estimator.rst (:243) - CS examples still fit with the deprecated kwarg; migrate to post-fit `results.aggregate('event_study')` (the two HAD examples in the same file were migrated with M-027) | `docs/troubleshooting.rst` | 2(b) PR-4 | Quick | Low | | Evaluate adding the `BaseEstimator` param surface (get_params/set_params) to the exported classes that never had it - `PowerAnalysis`, `LinearRegression`, `BusinessReport`, `DiagnosticReport`, `TWFEWeightsResult` (a NEW public surface, deliberately out of the 2(c)-i pure-refactor scope; `LinearRegression` is the one `fit`-bearing class excluded from the contract suite's roster-completeness test). | `diff_diff/linalg.py`, `diff_diff/power.py` | mixin PR | Mid | Low | | Tighten the mypy suppressions that back the enforced-zero posture: burn down `prep_dgp`'s per-module `[index]` override (needs a None-vs-array restructure that preserves the seeded RNG stream), and evaluate re-enabling the globally disabled codes (`arg-type`, `return-value`, `var-annotated`, `assignment`) one at a time — `assignment` alone hid several real annotation drifts found during the 2026-07 triage. | `pyproject.toml` `[tool.mypy]`, `diff_diff/prep_dgp.py` | lint-CI | Mid | Low | | MMM interop follow-up: Meridian `roi_calibration_period` mask builder - accept the MMM's time index + channel order and emit the boolean `(n_media_times, n_media_channels)` mask so `.to_code()` scopes the prior to the experiment window automatically (today the caller passes a mask expression / `full_model_window=True`). | `diff_diff/mmm.py` | mmm-interop | Quick | Low | diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py index 139e9d22..4ed94a20 100644 --- a/diff_diff/__init__.py +++ b/diff_diff/__init__.py @@ -549,7 +549,7 @@ "YatchewTestResults", "HADPretestReport", # HAD joint pre-tests (Phase 3 follow-up) — multi-period event-study - # workflow dispatch via did_had_pretest_workflow(aggregate="event_study") + # workflow dispatch selected from the panel shape (M-139) "stute_joint_pretest", "joint_pretrends_test", "joint_homogeneity_test", diff --git a/diff_diff/aggregation.py b/diff_diff/aggregation.py index 828cb842..fc6cf0c6 100644 --- a/diff_diff/aggregation.py +++ b/diff_diff/aggregation.py @@ -365,25 +365,35 @@ def summary(self, alpha: Optional[float] = None) -> str: n_label = "n" if self.n_kind is None else f"n[{self.n_kind}]" frame = self.to_dataframe() - if len(dict.fromkeys(self.target)) > 1: - # Heterogeneous targets (ContinuousDiD's att/acrt, row M-025): - # a target column disambiguates the duplicate labels and the - # estimate heading goes neutral - the hard-coded 'ATT' would - # mislabel every acrt row. Uniform-target containers render - # exactly as before (byte-stable). + distinct_targets = list(dict.fromkeys(self.target)) + if len(distinct_targets) > 1 or ( + len(distinct_targets) == 1 and distinct_targets[0] != "att" + ): + # Heterogeneous targets (ContinuousDiD's att/acrt, row M-025) + # OR a single non-'att' estimand (HAD's WAS/WAS_d_lower and + # dCDH's DID_M/DID_1/delta relays, row M-027 widening): a + # target column names the estimand and the estimate heading + # goes neutral - the hard-coded 'ATT' would mislabel those + # rows. Uniform-'att' containers render exactly as before + # (byte-stable). The target column widens to the longest + # label (dCDH's parenthetical estimand labels exceed 8 + # chars); ContinuousDiD's att/acrt keep the original 8-char + # width, so its output is byte-stable too. + t_w = max(8, max(len(str(t)) for t in distinct_targets)) + rule = 64 + t_w + 1 lines.append( - f"{'label':>14} {'target':>8} {'estimate':>11} {'SE':>10} " + f"{'label':>14} {'target':>{t_w}} {'estimate':>11} {'SE':>10} " f"{'t':>8} {'p':>8} {n_label:>10}" ) - lines.append("-" * 73) + lines.append("-" * rule) for _, row in frame.iterrows(): n_disp = "" if not np.isfinite(row["n"]) else f"{row['n']:.0f}" lines.append( - f"{str(row['label']):>14} {str(row['target']):>8} " + f"{str(row['label']):>14} {str(row['target']):>{t_w}} " f"{row['att']:>11.4f} {row['se']:>10.4f} " f"{row['t_stat']:>8.3f} {row['p_value']:>8.4f} {n_disp:>10}" ) - lines.append("-" * 73) + lines.append("-" * rule) else: lines.append(f"{'label':>14} {'ATT':>11} {'SE':>10} {'t':>8} {'p':>8} {n_label:>10}") lines.append("-" * 64) diff --git a/diff_diff/bootstrap_utils.py b/diff_diff/bootstrap_utils.py index c94b38b9..43e62f0c 100644 --- a/diff_diff/bootstrap_utils.py +++ b/diff_diff/bootstrap_utils.py @@ -677,7 +677,7 @@ def apply_stratum_centering( - HAD sup-t is a multiplier bootstrap on a precomputed influence tensor. The correction is applied to the tensor before - ``perturbations = psu_weights @ Psi_psu`` — see ``had.py:2151-2204``. + ``perturbations = psu_weights @ Psi_psu`` — see ``had.py:2264-2317``. - Stute is a wild residual bootstrap with refit-in-loop and a nonlinear functional. The correction is applied to the multipliers before the per-obs broadcast ``eta_obs = psu_mults[b, @@ -736,7 +736,7 @@ def apply_stratum_centering( along ``psu_axis`` and rescale by ``sqrt(n_psu / (n_psu - 1))``. This is the standard small-sample correction for an iid cluster wild bootstrap (Wu 1986; Liu 1988) and matches the HAD sup-t - convention at ``had.py:2199-2204``. + convention at ``had.py:2312-2317``. The Stute call site has historically NOT applied this correction (pre-PR Phase 4.5 C). Lifting the gate on stratified designs + diff --git a/diff_diff/efficient_did.py b/diff_diff/efficient_did.py index 9ee259fa..2edd7b85 100644 --- a/diff_diff/efficient_did.py +++ b/diff_diff/efficient_did.py @@ -503,10 +503,13 @@ def fit( act like ``None``, unchanged). Aggregate as a post-fit step instead: ``results.aggregate('event_study')`` / ``.aggregate('group')`` / ``.aggregate('simple')``. On - bootstrapped fits (``n_bootstrap > 0``) post-fit - ``aggregate()`` fails closed — the deprecated fit-time path - remains the supported route for a bootstrapped aggregated - surface. + bootstrapped fits (``n_bootstrap > 0``) the post-fit + RECOMPUTE levels (``'event_study'``/``'group'``) fail closed + — the deprecated fit-time path remains the supported route + for those bootstrapped aggregated surfaces — while + ``aggregate('simple')`` relays the stored bootstrap + inference and stays available (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()`` — diff --git a/diff_diff/efficient_did_results.py b/diff_diff/efficient_did_results.py index 004f60f0..1e5bf190 100644 --- a/diff_diff/efficient_did_results.py +++ b/diff_diff/efficient_did_results.py @@ -295,14 +295,25 @@ def _aggregate_compute( "an older release will not have one. Re-fit with " "diff-diff >= 3.9 to aggregate post-fit." ) + # Per-level bootstrap policy (v4-design section 6, converged with row + # M-027): 'simple' is a bit-exact RELAY of the stored overall row - + # faithful under any inference regime, bootstrap included - so it + # dispatches BEFORE the bootstrap gate. Only the RECOMPUTE levels + # below fail closed on bootstrapped fits. (This supersedes the + # uniform-conservatism decision recorded with M-023; its rationale - + # never publish analytical provenance beside percentile inference - + # is honored by the relay's NaN df column.) + if level == "simple": + return self._aggregate_simple_result(kit) if self.bootstrap_results is not None: raise NotImplementedError( - "aggregate() is not yet available on a bootstrapped fit " - "(n_bootstrap > 0): the per-horizon bootstrap draws are not " - "retained, so post-fit re-aggregation cannot replay " + f"aggregate({level!r}) is not yet available on a bootstrapped " + "fit (n_bootstrap > 0): the per-horizon bootstrap draws are " + "not retained, so post-fit re-aggregation cannot replay " "percentile inference and analytical inference would " - "misrepresent the fit. Re-fit with the aggregation you " - "need, or use n_bootstrap=0." + "misrepresent the fit. aggregate('simple') relays the stored " + "bootstrap inference and remains available; otherwise re-fit " + "with the aggregation you need, or use n_bootstrap=0." ) bk = dict(kit.bookkeeping) agg = _EDiDKitAggregator( @@ -312,8 +323,6 @@ def _aggregate_compute( resolved_survey_unit=bk["resolved_survey_unit"], unit_level_weights=bk["unit_level_weights"], ) - if level == "simple": - return self._aggregate_simple_result(kit) if level == "group": effects = agg._aggregate_by_group( bk["group_time_effects"], @@ -384,8 +393,12 @@ def _aggregate_simple_result(self, kit: Any) -> AggregationResult: keeps the resolved design's finite value), and the snapshot is provenance-exact in every state. None → all-NaN df column; the replicate-undefined 0-sentinel row NaNs out via post_init. + Bootstrapped fits relay the stored quintet verbatim (percentile + se/p/CI beside the finite ``safe_inference`` t) with a NaN df + column - no df governs percentile inference (the per-level policy + converged with row M-027). """ - df_val = kit.bookkeeping["df_survey"] + df_val = np.nan if self.bootstrap_results is not None else kit.bookkeeping["df_survey"] return AggregationResult( level="simple", label=np.array(["overall"], dtype=object), diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 086c98e6..5bc6030d 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -259,7 +259,7 @@ view. Every field below appears as a top-level key in that dict. doses), the situation is different: `ContinuousDiD` does not apply, and `HeterogeneousAdoptionDiD` is **not** a fallback either — HAD raises on negative post-period dose - (`had.py:1450-1459`). The applicable routing alternative on + (`had.py:1563-1572`). The applicable routing alternative on the negative-dose branch is linear DiD with the treatment as a signed continuous covariate. Re-encoding the treatment column to a non-negative scale (shifting, absolute value, etc.) @@ -399,8 +399,8 @@ supported / out of scope; `warn` supported but with documented caveats; dose-response estimator - use `ContinuousDiD` for that. - `HeterogeneousAdoptionDiD` staggered support is `partial`, not general. Paper Appendix B.2 restricts staggered use to the - **last treatment cohort plus never-treated units**. With - `aggregate="event_study"` and a `first_treat` kwarg, + **last treatment cohort plus never-treated units**. On a + multi-period panel with a `first_treat` kwarg, `fit()` auto-filters to `F_last = max(cohorts)` and emits a `UserWarning` naming kept/dropped counts; earlier-cohort units are dropped. Without `first_treat`, a multi-cohort panel @@ -614,7 +614,7 @@ When `treatment_type == "continuous"`: see §2 for the full routing-alternatives discussion (the two branches differ: HAD applies on the no-never-treated branch but not on the negative-dose branch, since HAD requires non-negative - dose support per `had.py:1450-1459`). + dose support per `had.py:1563-1572`). Note that staggered adoption IS supported natively (adoption timing is expressed via the `first_treat` column, not via within-unit dose variation), and `ContinuousDiD.fit()` applies @@ -671,9 +671,10 @@ intensity of exposure differs): untreated group exists (paper Equation 2 / Theorem 1). The `target_parameter` attribute on the results object is literally `"WAS"` for Design 1' and `"WAS_d_lower"` for Design 1 with lower-dose - comparison under Assumption 6. `fit(aggregate="overall")` (Phase 2a) - returns a single scalar WAS; `fit(aggregate="event_study")` (Phase - 2b) returns per-event-time WAS estimates. `did_had_pretest_workflow()` + 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()` 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 @@ -685,7 +686,7 @@ intensity of exposure differs): **Staggered-timing scope is last-cohort-only (Appendix B.2).** HAD's staggered support is the `partial` cell in §3: on a - multi-cohort panel passed to `aggregate="event_study"`, `fit()` + multi-cohort multi-period panel (the event-study mode), `fit()` auto-filters to the last treatment cohort (`F_last = max(cohorts)`) plus never-treated units and emits a `UserWarning` naming kept/dropped counts; earlier treated @@ -943,7 +944,7 @@ Reasoning chain: would raise at line 287-294 ("Dose must be strictly positive for treated units"). `HeterogeneousAdoptionDiD` is **not** a routing alternative here either — HAD requires non-negative - dose support (`had.py:1450-1459`, paper Section 2). The + dose support (`had.py:1563-1572`, paper Section 2). The applicable alternative is linear DiD with the treatment as a signed continuous covariate; see §2 for the full routing discussion. diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 07baa237..bc12c234 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -228,7 +228,8 @@ cs.fit( from diff_diff import CallawaySantAnna, plot_event_study # NOTE: no n_bootstrap here - post-fit aggregate() is ANALYTICAL-ONLY and -# raises NotImplementedError on a bootstrapped fit (see below). +# recompute levels raise NotImplementedError on a bootstrapped fit +# ('simple' relays the stored bootstrap inference - see below). cs = CallawaySantAnna(estimation_method="dr") results = cs.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat') @@ -245,7 +246,7 @@ all accept the post-fit container directly - `plot_event_study`, `compute_honest_did` and `compute_pretrends_power` each take `results.aggregate('event_study')` - so the only case still requiring the fit-time path is **bootstrap inference**: CallawaySantAnna's `aggregate()` -raises on a bootstrapped fit rather than substituting analytical inference for +fails closed on a bootstrapped fit's RECOMPUTE levels ('event_study'/'group') rather than substituting analytical inference for percentile-bootstrap statistics. ```python @@ -824,11 +825,11 @@ had.fit( time: str, unit: str, first_treat: str | None = None, # Required on staggered panels (last-cohort auto-filter trigger) - aggregate: str = "overall", # "overall" (single scalar WAS) or "event_study" (per-horizon WAS) + 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 - trends_lin: bool = False, # Eq 17 linear-trend detrending. Requires aggregate="event_study"; needs F>=3 (pre-period depth) for the regression; rejects survey_design= (raises NotImplementedError under trends_lin). + trends_lin: bool = False, # Eq 17 linear-trend detrending. Requires a multi-period panel (the event-study mode); needs F>=3 (pre-period depth) for the regression; rejects survey_design= (raises NotImplementedError under trends_lin). covariates: Any | None = None, # NOT IMPLEMENTED — non-None raises NotImplementedError (deferred Appendix B.1 / Theorem 6 covariate-adjusted extension; pre-residualize the outcome on covariates as a workaround) outcome_col: str = ..., dose_col: str = ..., time_col: str = ..., unit_col: str = ..., first_treat_col: str = ..., # deprecated aliases for the five column params (FutureWarning; removed in 4.0) ) -> HeterogeneousAdoptionDiDResults | HeterogeneousAdoptionDiDEventStudyResults @@ -839,10 +840,10 @@ had.fit( ```python from diff_diff import HeterogeneousAdoptionDiD, did_had_pretest_workflow -# `aggregate="overall"` (the default) is two-period-only: did_had_pretest_workflow -# reduces to a single first-difference and HeterogeneousAdoptionDiD.fit hard-rejects -# panels with more than two periods. `aggregate="event_study"` requires a -# multi-period panel. Use distinct data objects for the two regimes. +# Both surfaces select their mode from the panel shape (M-027/M-139): a +# two-period panel runs the overall (single-period WAS) estimator / pretest +# battery, a multi-period panel runs the event-study ones. Use distinct data +# objects for the two regimes. # Vet the testable identifying assumptions on the two-period panel first: report = did_had_pretest_workflow( @@ -850,21 +851,24 @@ report = did_had_pretest_workflow( dose='d', first_treat='first_treat') print(report.summary()) -# Single-period scalar WAS (aggregate="overall" default) on the two-period panel: +# Single-period scalar WAS (the overall mode) on the two-period panel: est = HeterogeneousAdoptionDiD() results = est.fit(data_2p, outcome='y', unit='unit', time='t', dose='d', first_treat='first_treat') print(results.summary()) -# Multi-period per-horizon WAS on the multi-period panel: +# Multi-period per-horizon WAS (the event-study mode) on the multi-period panel: es = est.fit(data_mp, outcome='y', unit='unit', time='t', dose='d', - first_treat='first_treat', - aggregate='event_study') + first_treat='first_treat') + +# Post-fit aggregation (rows M-027/M-122) - pure views, no kit: +print(results.aggregate('simple').to_dataframe()) # 1-row WAS relay (target = the estimand label) +print(es.aggregate('event_study').to_dataframe()) # unified EventStudyResults container ``` -**Staggered panels.** On multi-cohort panels with `aggregate="event_study"`, `fit()` auto-filters to the last treatment cohort plus never-treated units (paper Appendix B.2) and emits a `UserWarning` naming kept/dropped counts. The estimand is then a **last-cohort-only WAS**, not a multi-cohort average. For full multi-cohort staggered support, see `ChaisemartinDHaultfoeuille`. +**Staggered panels.** On multi-cohort multi-period panels (the event-study mode), `fit()` auto-filters to the last treatment cohort plus never-treated units (paper Appendix B.2) and emits a `UserWarning` naming kept/dropped counts. The estimand is then a **last-cohort-only WAS**, not a multi-cohort average. For full multi-cohort staggered support, see `ChaisemartinDHaultfoeuille`. **Mass-point + survey constraint.** When fitting `design="mass_point"` with `survey_design=SurveyDesign(...)`, `vcov_type="hc1"` is required: the survey path composes the standard error via Binder-TSL on the HC1-scale influence function, so the default classical sandwich path raises `NotImplementedError` — on both the static path and the event-study path (the event-study rejection fires regardless of `cband`, since the Binder-TSL analytical SE consumes the HC1-scaled IF either way). The one exception is `cluster=`: a clustered mass-point fit resolves to the CR1 sandwich regardless of `vcov_type` (and uses the clustered sup-t band), so there is no classical-vs-HC1 mismatch. Passing `vcov_type="hc1"` is a safe default on weighted survey + sup-t examples since `vcov_type` is unused on the continuous designs (CCT-2014 robust SE is the only formula there). @@ -1108,7 +1112,8 @@ edid = EfficientDiD(pt_assumption="all") results = edid.fit(data, outcome='y', unit='id', time='t', first_treat='first_treat') results.print_summary() -# Aggregate post-fit (recomputed from retained EIFs; bootstrapped fits raise): +# Aggregate post-fit (recomputed from retained EIFs; on bootstrapped fits +# the recompute levels raise while 'simple' relays the stored inference): es = results.aggregate('event_study') # EventStudyResults container grp = results.aggregate('group') # per-cohort AggregationResult print(grp.to_dataframe()) @@ -1616,8 +1621,10 @@ Returned by `CallawaySantAnna.fit()`. refit, returning a NEW object and leaving the result unchanged. `"event_study"` returns `EventStudyResults`; the others return `AggregationResult`. `balance_e=` applies to `"event_study"` only. Raises on -`"calendar"` (CS has no calendar aggregator) and on a bootstrapped fit, rather -than substituting analytical inference for percentile-bootstrap statistics. +`"calendar"` (CS has no calendar aggregator) and, on a bootstrapped fit, on +the recompute levels (`"event_study"`/`"group"`) rather than substituting +analytical inference for percentile-bootstrap statistics - `"simple"` relays +the stored bootstrap inference with a NaN df column (the per-level rule). ### SunAbrahamResults @@ -1796,7 +1803,7 @@ Each event study effect dict contains: `effect`, `se`, `t_stat`, `p_value`, `con | `n_clusters` | `int | None` | Number of effective clusters; `None` under survey designs and `None` under EfficientDiD's default unclustered fit | | `df_convention` | `str | None` | On the knob-carrying containers (`StackedDiDResults`, `ImputationDiDResults`, `WooldridgeDiDResults`, `LPDiDResults`, `SunAbrahamResults`): the configured df convention (3.9 / M-127); `StackedDiDResults` additionally carries `inference_df` (the overall-ATT df actually used) | -**Methods:** `summary()`, `print_summary()`, `to_dataframe()`, `to_dict()` (flat dict of headline aliases + `vcov_type` + conditional `cluster_name`/`n_clusters`/`n_bootstrap`/`inference_method`); `aggregate(type, weights=None, *, balance_e=None)` on the shipped post-fit adopters (`StackedDiDResults` views since 3.9/M-024; `EfficientDiDResults` recomputes from retained EIFs since 3.9/M-023; `ImputationDiDResults` and `TwoStageDiDResults` recompute from their PANEL-BACKED kits since 3.9/M-021/M-022 - bootstrapped fits raise on all three recomputing adopters; `ContinuousDiDResults` is MIXED since 3.9/M-025 - `'simple'`/`'dose'` are views over stored fields that work on any fit incl. bootstrapped, `'event_study'` recomputes from a pruned per-cell IF kit and raises on bootstrapped fits) +**Methods:** `summary()`, `print_summary()`, `to_dataframe()`, `to_dict()` (flat dict of headline aliases + `vcov_type` + conditional `cluster_name`/`n_clusters`/`n_bootstrap`/`inference_method`); `aggregate(type, weights=None, *, balance_e=None)` on the shipped post-fit adopters (`StackedDiDResults` views since 3.9/M-024; `EfficientDiDResults` recomputes from retained EIFs since 3.9/M-023; `ImputationDiDResults` and `TwoStageDiDResults` recompute from their PANEL-BACKED kits since 3.9/M-021/M-022 - on bootstrapped fits their recompute levels raise while `'simple'` relays the stored bootstrap inference with a NaN df column (the per-level rule, converged with M-027); `ContinuousDiDResults` is MIXED since 3.9/M-025 - `'simple'`/`'dose'` are views over stored fields that work on any fit incl. bootstrapped, `'event_study'` recomputes from a pruned per-cell IF kit and raises on bootstrapped fits; `HeterogeneousAdoptionDiDResults` and `HeterogeneousAdoptionDiDEventStudyResults` are pure views since 3.9/M-027 - `'simple'` on the overall class, `'event_study'` on the event-study class, no kit, work on pickles from any release) ### ContinuousDiDResults @@ -1866,11 +1873,11 @@ Single-period results container for `HeterogeneousAdoptionDiD`. The table below | `variance_formula` | `str | None` | HAD-specific SE label on weighted fits, populated on BOTH continuous and mass-point designs: `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path) or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path; requires `vcov_type="hc1"` — the mass-point survey path rejects `vcov_type="classical"`, and `cluster=` + `survey_design=` is rejected, so PSU clustering is expressed via `SurveyDesign(weights='', psu='')`). `None` on unweighted fits | | `effective_dose_mean` | `float | None` | Weighted denominator used by the β̂-scale rescaling, populated on weighted fits across all designs: weighted `mean(d)` (`continuous_at_zero`), weighted `mean(d − d_lower)` (`continuous_near_d_lower`), or weighted Wald-IV dose gap `mean(d | Z=1, w) − mean(d | Z=0, w)` (`mass_point`). `None` on unweighted fits | -**Methods:** `summary()`, `print_summary()`, `to_dict()`, `to_dataframe()` +**Methods:** `summary()`, `print_summary()`, `to_dict()`, `to_dataframe()`, `aggregate(type, ...)` (M-027 pure view: `'simple'` only - a one-row bit-exact relay whose `target` is the estimand label `WAS`/`WAS_d_lower`; no kit, works on pickles from any release; `'event_study'` needs a multi-period fit) ### HeterogeneousAdoptionDiDEventStudyResults -Per-horizon event-study results container for `HeterogeneousAdoptionDiD` with `aggregate="event_study"`. The anchor horizon `e = -1` is excluded by construction. The table below enumerates every public dataclass field; a regression test (`test_llms_full_had_results_class_field_lists_match_real_dataclass`) compares this list against the real `dataclasses.fields()`. +Per-horizon event-study results container for `HeterogeneousAdoptionDiD`'s event-study mode (multi-period panels; M-027). The anchor horizon `e = -1` is excluded by construction. The table below enumerates every public dataclass field; a regression test (`test_llms_full_had_results_class_field_lists_match_real_dataclass`) compares this list against the real `dataclasses.fields()`. | Attribute | Type | Description | |-----------|------|-------------| @@ -1904,7 +1911,7 @@ Per-horizon event-study results container for `HeterogeneousAdoptionDiD` with `a | `cband_method` | `str | None` | `"multiplier_bootstrap"` (weighted/survey band) or `"cluster_multiplier_bootstrap"` (clustered band) when populated | | `cband_n_bootstrap` | `int | None` | Bootstrap iterations used for the band | -**Methods:** `summary()`, `print_summary()`, `to_dict()`, `to_dataframe()` +**Methods:** `summary()`, `print_summary()`, `to_dict()`, `to_dataframe()`, `aggregate(type, ...)` (M-027 pure view: `'event_study'` only - the unified `EventStudyResults` container via the `_from_had` passthrough, cband fields included; no kit; `'simple'` needs a two-period fit - no overall WAS is stored here) ### ChangesInChangesResults @@ -2022,7 +2029,7 @@ results = did.fit(data, outcome='y', treatment='treated', post='post') ## HAD Pretests -Diagnostic pretests for the `HeterogeneousAdoptionDiD` identifying assumptions (de Chaisemartin, Ciccia, D'Haultfœuille & Knau 2026). The composite workflow `did_had_pretest_workflow` is the recommended entry point — call it before reporting WAS as causal. The workflow follows paper Section 4.2's three-step battery: **step 1** is the QUG support-infimum test (decides whether Design 1' or Design 1 applies); **step 2** is the Assumption 7 pre-trends test (joint Stute on the event-study path; explicitly NOT covered on the overall path because a single-pre-period panel cannot support the joint variant); **step 3** is the Assumption 8 linearity test (`stute_test` or `yatchew_hr_test`). On the default `aggregate="overall"` path the workflow runs steps 1 + 3 only and the returned `verdict` flags the Assumption 7 gap; pass `aggregate="event_study"` on a multi-period panel **with at least one earlier placebo pre-period beyond the base `F-1`** to close that gap. With only the base `F-1` pre-period available (minimal 3-period event-study, or 4-period under `trends_lin=True` where the consumed `F-2` placebo is dropped), the workflow still sets `pretrends_joint=None`, `all_pass=False`, and appends `joint pre-trends skipped (no earlier pre-period)` to the verdict — step 2 stays uncovered. +Diagnostic pretests for the `HeterogeneousAdoptionDiD` identifying assumptions (de Chaisemartin, Ciccia, D'Haultfœuille & Knau 2026). The composite workflow `did_had_pretest_workflow` is the recommended entry point — call it before reporting WAS as causal. The workflow follows paper Section 4.2's three-step battery: **step 1** is the QUG support-infimum test (decides whether Design 1' or Design 1 applies); **step 2** is the Assumption 7 pre-trends test (joint Stute on the event-study path; explicitly NOT covered on the overall path because a single-pre-period panel cannot support the joint variant); **step 3** is the Assumption 8 linearity test (`stute_test` or `yatchew_hr_test`). On a two-period panel the workflow runs the overall battery (steps 1 + 3 only) and the returned `verdict` flags the Assumption 7 gap; a multi-period panel selects the event-study battery (M-139 - the mode is panel-inferred, like `fit()`), which closes that gap **when at least one earlier placebo pre-period beyond the base `F-1` exists**. With only the base `F-1` pre-period available (minimal 3-period event-study, or 4-period under `trends_lin=True` where the consumed `F-2` placebo is dropped), the workflow still sets `pretrends_joint=None`, `all_pass=False`, and appends `joint pre-trends skipped (no earlier pre-period)` to the verdict — step 2 stays uncovered. ```python from diff_diff import ( @@ -2031,16 +2038,15 @@ from diff_diff import ( stute_joint_pretest, joint_pretrends_test, joint_homogeneity_test, ) -# Composite workflow: -# aggregate="overall" -> steps 1 + 3 (QUG + Assumption 8 linearity) -# step 2 (Assumption 7 pre-trends) NOT covered; -# verdict explicitly flags this gap. -# aggregate="event_study" -> steps 1 + 2 + 3 (QUG + joint Stute pre-trends + -# joint homogeneity-linearity Stute) on multi-period panels. +# Composite workflow - the battery is selected from the panel shape (M-139): +# two-period panel -> steps 1 + 3 (QUG + Assumption 8 linearity) +# step 2 (Assumption 7 pre-trends) NOT covered; +# verdict explicitly flags this gap. +# multi-period panel -> steps 1 + 2 + 3 (QUG + joint Stute pre-trends + +# joint homogeneity-linearity Stute). report = did_had_pretest_workflow( data, outcome='y', unit='unit', time='t', dose='d', first_treat='first_treat', - aggregate='overall', survey_design=None) # SurveyDesign for survey-aware pretests (Phase 4.5 C) print(report.summary()) print(report.all_pass, report.verdict) diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index 2198e487..90fecf49 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -64,8 +64,10 @@ results.conf_int # Confidence interval # CallawaySantAnna (with aggregate parameter): results.overall_att # Overall ATT results.overall_se # Standard error -results.group_effects # Per-cohort ATTs (aggregate='group' or 'all') -results.event_study_effects # Dynamic effects (aggregate='event_study' or 'all') +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')) # SunAbraham (no aggregate parameter — effects computed automatically): results.overall_att # Overall ATT @@ -145,15 +147,14 @@ universal pre-periods, which is invalid when cohorts adopt at different times (some "pre-periods" are post-treatment for early cohorts). Instead, use the CS event-study pre-period coefficients as the pre-trends diagnostic: ```python -# Fit CS with event_study aggregation, then inspect pre-periods +# Fit CS, then inspect the post-fit event-study pre-periods cs = CallawaySantAnna(control_group='never_treated', cluster='unit_id') -results = cs.fit(data, ..., aggregate='event_study') +results = cs.fit(data, ...) # Pre-treatment relative-time ATTs should be near zero -if results.event_study_effects: - for rel_t, eff in sorted(results.event_study_effects.items()): - if rel_t < 0: - print(f"Pre-period {rel_t}: ATT={eff['effect']:.4f}, SE={eff['se']:.4f}") +es = results.aggregate('event_study').to_dataframe() +for _, row in es[es['event_time'] < 0].iterrows(): + print(f"Pre-period {row['event_time']:.0f}: ATT={row['att']:.4f}, SE={row['se']:.4f}") # Significant pre-treatment effects → parallel trends may be violated ``` @@ -191,9 +192,10 @@ see REGISTRY HeterogeneousAdoptionDiD edge cases): | never-treated share — the never-treated units are retained | as controls and do not break HAD's identification (paper | Appendix B.2; REGISTRY edge cases). Panel-shape contract -| is load-bearing on HAD: aggregate='overall' (the default) -| is two-period only and hard-rejects multi-period panels; -| multi-period panels MUST set aggregate='event_study'. On +| is load-bearing on HAD, and fit() selects the mode from +| it (M-027): two-period panels run the overall (single- +| period WAS) estimator; multi-period panels run the per- +| horizon event-study estimator. On | STAGGERED multi-cohort panels HAD's event-study path auto- | filters to the last cohort + never-treated (Appendix B.2) | and the estimand becomes last-cohort-only WAS — use @@ -288,9 +290,11 @@ results = cs.fit( time='year', first_treat='first_treat', covariates=['lpop'], - aggregate='all', # computes simple, event_study, and group aggregations ) print(results.summary()) +# Aggregations are post-fit views/recomputes - no refit: +print(results.aggregate('event_study').to_dataframe()) +print(results.aggregate('group').to_dataframe()) ``` ### Simple 2x2 @@ -349,8 +353,10 @@ 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). EXCEPTION: on a - BOOTSTRAPPED fit (`n_bootstrap > 0`) post-fit `aggregate()` raises; - use the retained fit-time `aggregate='event_study'` there. + BOOTSTRAPPED fit (`n_bootstrap > 0`) the post-fit recompute levels + (`'event_study'`/`'group'`) raise, while `aggregate('simple')` + relays the stored bootstrap inference; use the retained fit-time + `aggregate='event_study'` for a bootstrapped event-study surface. - For dCDH: requires `L_max >= 1` (multi-horizon mode). Bounds use placebo estimates `DID^{pl}_l` as pre-period coefficients rather than standard event-study pre-treatment coefficients, and use diagonal variance (no @@ -427,9 +433,10 @@ print(results.aggregate('group').to_dataframe()) # Per-cohort ATTs print(results.aggregate('event_study').to_dataframe()) # Dynamic effects # balance_e moves onto aggregate() too: print(results.aggregate('event_study', balance_e=2).to_dataframe()) -# EXCEPTION: a BOOTSTRAPPED fit's aggregate() raises on the recomputing -# adopters (CS/EfficientDiD/ImputationDiD/TwoStageDiD) — use the -# deprecated fit-time aggregation there: +# EXCEPTION: on a BOOTSTRAPPED fit the RECOMPUTE levels +# (event_study/group) raise on CS/EfficientDiD/ImputationDiD/TwoStageDiD +# — aggregate('simple') relays the stored bootstrap inference; use the +# deprecated fit-time aggregation for a bootstrapped ES/group surface: results = cs.fit(data, ..., aggregate='all') ``` @@ -633,25 +640,26 @@ cs = CallawaySantAnna( cluster='countyreal', ) results = cs.fit(data, outcome='lemp', unit='countyreal', time='year', - first_treat='first_treat', covariates=['lpop'], - aggregate='all') + first_treat='first_treat', covariates=['lpop']) print(results.summary()) # Step 3 (continued): Inspect CS event-study pre-period coefficients -# Pre-treatment relative-time ATTs should be near zero and insignificant. -if results.event_study_effects: - for rel_t, eff in sorted(results.event_study_effects.items()): - if rel_t < 0: - print(f" Pre-period {rel_t}: ATT={eff['effect']:.4f}, " - f"SE={eff['se']:.4f}") +# (post-fit container; pre-treatment ATTs should be near zero and +# insignificant). +es_df = results.aggregate('event_study').to_dataframe() +for _, row in es_df[es_df['event_time'] < 0].iterrows(): + print(f" Pre-period {row['event_time']:.0f}: ATT={row['att']:.4f}, " + f"SE={row['se']:.4f}") # Step 6: Sensitivity — HonestDiD bounds honest = compute_honest_did(results, method='relative_magnitude', M=1.0) print(honest.summary()) -# Step 7: Heterogeneity — group and event study (already computed via aggregate='all') -print("Group effects:", results.group_effects) -print("Event study:", results.event_study_effects) +# Step 7: Heterogeneity — group and event study, post-fit (no refit) +print("Group effects:") +print(results.aggregate('group').to_dataframe()) +print("Event study:") +print(results.aggregate('event_study').to_dataframe()) # Step 8: Robustness — compare estimators sa = SunAbraham(cluster='countyreal') @@ -668,7 +676,7 @@ print(f"BJS ATT: {bjs_result.overall_att:.4f} (SE: {bjs_result.overall_se:.4f})" # Step 8 (continued): REQUIRED with/without covariates comparison results_no_cov = cs.fit(data, outcome='lemp', unit='countyreal', time='year', - first_treat='first_treat', aggregate='all') + first_treat='first_treat') print(f"Without covariates: ATT={results_no_cov.overall_att:.4f}") print(f"With covariates: ATT={results.overall_att:.4f}") diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index cf92172b..b1d76469 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). 6. **Sensitivity analysis** — `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; a StackedDiD `results.aggregate('event_study')` container also admits - 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; EXCEPTION: a BOOTSTRAPPED CS fit's `aggregate()` raises — use the fit-time aggregation there); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; Stacked: `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; bootstrapped EDiD fits raise - use the fit-time aggregation there; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/Gardner: `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')`; bootstrapped fits raise - use the deprecated fit-time aggregation there; 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); subgroup re-estimation. +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; EXCEPTION: on a BOOTSTRAPPED CS fit the recompute levels `'event_study'`/`'group'` raise while `.aggregate('simple')` relays the stored bootstrap inference (NaN df column); use the fit-time aggregation for a bootstrapped event-study surface); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; Stacked: `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 raise while `.aggregate('simple')` relays the stored bootstrap inference - use the fit-time aggregation for a bootstrapped ES/group surface; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/Gardner: `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 @@ -70,7 +70,7 @@ The site is organized into 5 sections, each with a landing page: - [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html): Abadie, Diamond & Hainmueller (2010) classic synthetic control for ONE treated unit — donor-weight counterfactual, predictor-importance V via nested / cv (out-of-sample, ADH 2015; needs predictors spanning both train/val windows, so default single-period lags are rejected) / inverse-variance (1/Var on raw predictors, bypasses standardize) / custom, gap path + pre-RMSPE; no analytical SE (inference fields NaN), significance via in-space placebo permutation inference (`in_space_placebo()`, post/pre RMSPE-ratio, p = rank/(n_placebos+1)); ADH-2015 §4 robustness: `leave_one_out()` donor-robustness + `in_time_placebo()` backdating placebo; confidence sets by test inversion (Firpo-Possebom 2018 §4): `test_sharp_null()` + `confidence_set(family="constant"|"linear")` re-rank the placebo gaps into a confidence set for the effect path (the analytical `conf_int` stays NaN); conformal inference (Chernozhukov-Wüthrich-Zhu 2021): `conformal_test()` (joint sharp-null p-value), `conformal_confidence_intervals()` (pointwise per-period CIs), `conformal_average_effect()` (average-effect CI) — fit their OWN constrained-LS proxy under the null on all periods and permute residuals over time (moving-block / iid schemes; `conf_int` still NaN) - [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html): Triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility - [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html): Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves -- [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html): de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲=0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲>0`, continuous-near-d̲ or mass-point), with multi-period event-study extension (last-treatment cohort, pointwise CIs). **Panel-only** in this release (repeated cross-sections rejected by the validator). Alias `HAD`. +- [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html): de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲=0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲>0`, continuous-near-d̲ or mass-point), with multi-period event-study extension (last-treatment cohort, pointwise CIs; the mode is panel-inferred since 3.9 - two periods -> overall WAS, more -> event-study - and post-fit `results.aggregate('simple')`/`.aggregate('event_study')` are pure views, rows M-027/M-139). **Panel-only** in this release (repeated cross-sections rejected by the validator). Alias `HAD`. - [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html): Calonico, Cattaneo & Titiunik (2014) sharp AND fuzzy regression discontinuity with robust bias-corrected inference, parity-targeting R rdrobust 4.0.0 (all 10 data-driven bandwidth selectors, mass-point handling, three-row conventional/bias-corrected/robust output; canonical `att` = the bias-corrected estimate with a coherent robust CI - rdrobust's printed headline is `att_conventional`). Fuzzy via `fit(..., takeup=...)`: local Wald ratio (complier LATE for binary take-up under monotonicity; ratio-of-jumps otherwise - the `estimand` field says which), first-stage `first_stage*` block, weak-first-stage warning. Covariate adjustment via `fit(..., covariates=[...])` (CCFT 2019 additive common-coefficient, R's `covs=`): SAME estimand, precision only; requires covariate balance at the cutoff (testable: fit each covariate as the outcome); covariate-aware bandwidths; collinear columns dropped with a warning (`covs_drop`). Cluster-robust variance is a documented follow-up. Alias `RDD`. - [StackedDiD](https://diff-diff.readthedocs.io/en/stable/api/stacked_did.html): Wing, Freedman & Hollingsworth (2024) stacked DiD with Q-weights and sub-experiments; optional covariate balancing (`balance="entropy"`, Ustyuzhanin 2026) - [EfficientDiD](https://diff-diff.readthedocs.io/en/stable/api/efficient_did.html): Chen, Sant'Anna & Xie (2025) efficient DiD with optimal weighting for tighter SEs diff --git a/diff_diff/had.py b/diff_diff/had.py index e3ae0a5e..6e6c5a95 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -38,9 +38,10 @@ error via the structural-residual 2SLS sandwich (see ``_fit_mass_point_2sls``). -Phase 2a ships the single-period WAS estimator (``aggregate="overall"``). -Phase 2b adds the multi-period event-study extension (paper Appendix B.2) -via ``aggregate="event_study"``: per-horizon WAS estimates with pointwise +Phase 2a ships the single-period WAS estimator (the two-period "overall" +mode). Phase 2b adds the multi-period event-study extension (paper +Appendix B.2) - the "event_study" mode, selected automatically from the +panel shape (rows M-027/M-139): per-horizon WAS estimates with pointwise CIs, including pre-period placebos, reusing the three Phase 2a design paths on per-horizon first differences anchored at ``Y_{g, F-1}``. Staggered-timing panels are auto-filtered to the last-treatment cohort @@ -66,7 +67,7 @@ import warnings from dataclasses import dataclass -from typing import Any, Dict, List, Literal, Optional, Tuple, Union, overload +from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union, overload import numpy as np import pandas as pd @@ -78,6 +79,7 @@ resolve_renamed_kwarg, warn_deprecated_kwarg, ) +from diff_diff.aggregation import AggregationMixin, AggregationResult, resolve_inference_df from diff_diff.bootstrap_chunking import ( compute_block_size, iter_survey_multiplier_weight_blocks, @@ -88,7 +90,7 @@ BiasCorrectedFit, bias_corrected_local_linear, ) -from diff_diff.results_base import BaseResults +from diff_diff.results_base import BaseResults, build_event_study_surface from diff_diff.survey import ( SurveyMetadata, compute_survey_metadata, @@ -125,6 +127,30 @@ "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). + + ``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 column check mirrors ``_validate_had_panel`` so a missing time + column stays a ``ValueError`` naming the column (never a raw + ``KeyError`` from the indexing below - the shared validators run + downstream of mode selection). + """ + if time_col not in data.columns: + raise ValueError(f"Missing column(s) in data: [{time_col!r}].") + n_periods = int(data[time_col].nunique(dropna=True)) + return "overall" if n_periods <= 2 else "event_study" + + # Mass-point 2SLS supports: classical, hc1 (HC0 is an unscaled variant not # publicly exposed; hc2 and hc2_bm raise NotImplementedError pending a # 2SLS-specific leverage derivation and R parity anchor). @@ -205,7 +231,7 @@ def _json_safe_filter_info( @dataclass -class HeterogeneousAdoptionDiDResults(BaseResults): +class HeterogeneousAdoptionDiDResults(BaseResults, AggregationMixin): """Estimator output for :class:`HeterogeneousAdoptionDiD`. NaN-safe inference: the three downstream fields ``t_stat``, @@ -553,9 +579,68 @@ def to_dataframe(self) -> pd.DataFrame: """Return a one-row DataFrame of the result dict.""" return pd.DataFrame([self.to_dict()]) + # ------------------------------------------------------------------ + # Post-fit aggregation (rows M-027/M-122): PURE VIEW, no kit. + # ------------------------------------------------------------------ + #: The overall (two-period) fit supports only the 'simple' relay - + #: the event-study estimand needs a multi-period panel and is not + #: recomputable from this container (the two modes share no + #: computable estimand; joint cross-horizon covariance is deferred). + _AGGREGATE_SUPPORTED: ClassVar[Tuple[str, ...]] = ("simple",) + #: balance_e applies to no aggregation type on this estimator. + _AGGREGATE_BALANCE_E_TYPES: ClassVar[Tuple[str, ...]] = () + + def _aggregate_compute( + self, level: str, *, weights: Optional[str], balance_e: Optional[int] + ) -> Any: + """One-row relay of the stored overall inference (bit-exact). + + A pure VIEW over stored public fields (the dCDH class): nothing + is recomputed, no kit is retained, and results unpickled from + any release aggregate identically. ``target`` relays + ``target_parameter`` ("WAS" / "WAS_d_lower") - the estimand is + a Weighted Average Slope, not an ATT, so the label column + carries the estimand name (the dCDH estimand-label precedent). + + ``n = n_obs`` with ``n_kind="units"``: the estimator's own + contributing-unit count (post panel-aggregation to unit-level + first differences). On the continuous designs this equals the + disjoint ``n_treated + n_control`` total by construction; the + mass-point masks can overlap in a ~1-ULP tolerance band at the + ``d_lower`` boundary, which is why ``n_obs`` (the single + authoritative source) is relayed rather than the sum. + + ``df`` relays :func:`resolve_inference_df`: the survey path + passes ``resolved_survey_unit.df_survey`` into the stored + ``safe_inference`` call and mirrors it on + ``survey_metadata.df_survey``, so the relay is + provenance-exact; plain fits passed ``df=None`` (normal + theory) and report a NaN df column. HAD rejects + replicate-weight designs at fit, so the replicate-undefined + 0-sentinel branch is unreachable here. + """ + # The mixin has already validated level/'weights'/'balance_e'. + return AggregationResult( + level="simple", + label=np.array(["overall"], dtype=object), + target=np.array([self.target_parameter], dtype=object), + att=np.array([self.att], dtype=float), + se=np.array([self.se], dtype=float), + t_stat=np.array([self.t_stat], dtype=float), + p_value=np.array([self.p_value], dtype=float), + conf_int_lower=np.array([self.conf_int[0]], dtype=float), + conf_int_upper=np.array([self.conf_int[1]], dtype=float), + n=np.array([float(self.n_obs)], dtype=float), + df=resolve_inference_df(self), + alpha=self.alpha, + n_kind="units", + weight=np.array([1.0], dtype=float), + estimator="HeterogeneousAdoptionDiD", + ) + @dataclass -class HeterogeneousAdoptionDiDEventStudyResults(BaseResults): +class HeterogeneousAdoptionDiDEventStudyResults(BaseResults, AggregationMixin): """Event-study results for :class:`HeterogeneousAdoptionDiD` (Phase 2b). Per-horizon arrays align with ``event_times`` by index; all per-horizon @@ -945,6 +1030,35 @@ def to_dataframe(self) -> pd.DataFrame: data["cband_high"] = self.cband_high return pd.DataFrame(data) + # ------------------------------------------------------------------ + # Post-fit aggregation (rows M-027/M-122): PURE VIEW, no kit. + # ------------------------------------------------------------------ + #: The event-study fit supports only the 'event_study' view - the + #: overall WAS is a different estimand on a different panel shape + #: (no pooled scalar is stored, and averaging per-horizon estimates + #: would need the deferred joint cross-horizon covariance for a + #: valid SE). + _AGGREGATE_SUPPORTED: ClassVar[Tuple[str, ...]] = ("event_study",) + #: balance_e applies to no aggregation type on this estimator. + _AGGREGATE_BALANCE_E_TYPES: ClassVar[Tuple[str, ...]] = () + + def _aggregate_compute( + self, level: str, *, weights: Optional[str], balance_e: Optional[int] + ) -> Any: + """The unified event-study surface as a pure view (bit-exact). + + Delegates to :func:`build_event_study_surface`, whose + ``_from_had`` adapter copies the stored per-horizon arrays - + including the simultaneous cband fields - verbatim. Nothing is + recomputed and no kit is retained, so results unpickled from + any release aggregate identically. The anchor horizon ``e = -1`` + is absent by design (its coefficient is identically zero and + the WAS is not identified there), so the container legitimately + has no reference row. + """ + # The mixin has already validated level/'weights'/'balance_e'. + return build_event_study_surface(self) + # ============================================================================= # Panel validation and aggregation @@ -1007,11 +1121,11 @@ def _validate_had_panel( ) if len(periods_list) > 2: raise ValueError( - f"HAD with aggregate='overall' requires exactly two time " - f"periods (got {len(periods_list)} in {time_col!r}). For " - f"multi-period panels, pass aggregate='event_study' (paper " - f"Appendix B.2 multi-period event-study extension) which " - f"produces per-event-time WAS estimates." + f"HAD's overall (two-period WAS) estimator requires exactly " + f"two time periods (got {len(periods_list)} in {time_col!r}). " + f"On a multi-period panel, a plain fit() selects the " + f"event-study mode automatically (paper Appendix B.2, " + f"per-event-time WAS estimates)." ) # Balanced-panel check: every unit appears exactly once per period. @@ -1209,10 +1323,10 @@ def _validate_had_panel_event_study( periods_list = list(data[time_col].unique()) if len(periods_list) < 3: raise ValueError( - f"HAD with aggregate='event_study' requires more than two " - f"time periods (got {len(periods_list)} in {time_col!r}). " - f"For two-period panels, pass aggregate='overall' (Phase 2a " - f"single-period WAS)." + f"HAD's event-study estimator requires more than two time " + f"periods (got {len(periods_list)} in {time_col!r}). On a " + f"two-period panel, a plain fit() selects the overall " + f"(single-period WAS) mode automatically." ) # Ordered-time-type check. Paper Appendix B.2 event-time horizons @@ -1233,7 +1347,7 @@ def _validate_had_panel_event_study( or (isinstance(time_dtype, pd.CategoricalDtype) and bool(time_dtype.ordered)) ): raise ValueError( - f"HAD aggregate='event_study' requires an ordered time " + f"HAD's event-study estimator requires an ordered time " f"column. time={time_col!r} has dtype={time_dtype!r}, " f"which has no defined chronological order; raw sort would " f"fall back to lexicographic ordering and silently misindex " @@ -1242,7 +1356,7 @@ def _validate_had_panel_event_study( f"Convert the time column to numeric (e.g., integer year), " f"datetime, or ordered categorical " f"(``pd.Categorical(..., ordered=True, categories=[...])``) " - f"before calling fit() with aggregate='event_study'." + f"before calling fit() on the multi-period panel." ) # Construct the chronological sort key once, shared across every @@ -1414,9 +1528,10 @@ def _sort_key(x: Any) -> Tuple[bool, Any]: f"(F_last={F_last!r}), only {len(periods_list)} " f"distinct time periods remain in {time_col!r}. " f"Event-study requires >2 periods; the filtered " - f"panel is too small. Pass aggregate='overall' on " - f"a two-period subset, or supply data with more " - f"pre- or post-periods for the last cohort." + f"panel is too small. Subset the data to two " + f"periods for the overall (single-period WAS) " + f"estimator, or supply data with more pre- or " + f"post-periods for the last cohort." ) # Balanced panel on the (possibly-filtered) data: every unit appears @@ -2660,14 +2775,21 @@ class HeterogeneousAdoptionDiD(BaseEstimator): d_lower, and Design 1 mass-point (2SLS sample-average per paper Section 3.2.4). Two aggregation modes: - - ``aggregate="overall"`` (Phase 2a, default) returns a single-period - :class:`HeterogeneousAdoptionDiDResults` on a two-period panel. - - ``aggregate="event_study"`` (Phase 2b, paper Appendix B.2) returns - a :class:`HeterogeneousAdoptionDiDEventStudyResults` with per- - event-time WAS estimates on a multi-period panel, using a uniform - ``F-1`` anchor and pointwise CIs per horizon. Staggered-timing - panels auto-filter to the last-treatment cohort plus never-treated - units (paper Appendix B.2 prescription). + - The "overall" mode (Phase 2a; selected on two-period panels) + returns a single-period :class:`HeterogeneousAdoptionDiDResults`. + - The "event_study" mode (Phase 2b, paper Appendix B.2; selected on + multi-period panels) returns a + :class:`HeterogeneousAdoptionDiDEventStudyResults` with per- + event-time WAS estimates, using a uniform ``F-1`` anchor and + pointwise CIs per horizon. Staggered-timing panels auto-filter to + 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 + unified aggregation vocabulary. Parameters ---------- @@ -2938,7 +3060,7 @@ def fit( time: Any = NOT_SUPPLIED, unit: Any = NOT_SUPPLIED, first_treat: Any = NOT_SUPPLIED, - aggregate: str = "overall", + aggregate: Any = NOT_SUPPLIED, *, cband: bool = True, survey_design: Any = None, @@ -2952,13 +3074,15 @@ def fit( ) -> Union[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]: """Fit the HAD estimator. - ``aggregate="overall"`` (default) fits on a two-period panel and - returns a :class:`HeterogeneousAdoptionDiDResults` with the - single-period WAS estimate. ``aggregate="event_study"`` fits on - a multi-period panel (``T > 2``) and returns a + The estimation mode follows the panel shape: a two-period panel + fits the "overall" mode and returns a + :class:`HeterogeneousAdoptionDiDResults` with the single-period + WAS estimate; a multi-period panel (``T > 2``) fits the + "event_study" mode and returns a :class:`HeterogeneousAdoptionDiDEventStudyResults` with per- event-time WAS estimates using a uniform ``F-1`` anchor (paper - Appendix B.2). + Appendix B.2). The deprecated ``aggregate=`` override (row + M-027) warns and is removed in 4.0. Both the overall and event-study paths are **panel-only**: the paper (Section 2) defines HAD on panel or repeated-cross-section data, @@ -2996,20 +3120,23 @@ 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"} - ``"overall"`` (default): returns a single-period - :class:`HeterogeneousAdoptionDiDResults` (Phase 2a). Requires - exactly two time periods. - ``"event_study"`` (Phase 2b): returns a - :class:`HeterogeneousAdoptionDiDEventStudyResults` with per- - event-time WAS estimates on the multi-period panel (paper - Appendix B.2). Requires more than two time periods. Pointwise - CIs per horizon; joint cross-horizon covariance is deferred - to a follow-up PR. 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). + 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 @@ -3076,13 +3203,12 @@ def fit( Returns ------- HeterogeneousAdoptionDiDResults - When ``aggregate="overall"`` (the default; two-period only): - single-period WAS estimate plus shared metadata. + On a two-period panel (the overall mode): single-period WAS + estimate plus shared metadata. HeterogeneousAdoptionDiDEventStudyResults - When ``aggregate="event_study"`` (multi-period panel; on - staggered panels auto-filters to the last cohort plus - never-treated): per-event-time WAS estimates with per- - horizon arrays. + On a multi-period panel (the event-study mode; staggered + panels auto-filter to the last cohort plus never-treated): + per-event-time WAS estimates with per-horizon arrays. Notes ----- @@ -3099,6 +3225,26 @@ def fit( ``FutureWarning`` and will be removed in 4.0. """ qualname = "HeterogeneousAdoptionDiD.fit" + # 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, but only AFTER the + # time/time_col alias reconciliation below (both spellings are + # NOT_SUPPLIED twins until then - rows M-035..M-039), at the + # _VALID_AGGREGATES check site. + 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, + ) outcome = resolve_renamed_kwarg( qualname, "outcome_col", @@ -3136,7 +3282,13 @@ def fit( unit_col = unit first_treat_col = first_treat # ---- aggregate / survey_design validation ---- - if aggregate not in _VALID_AGGREGATES: + # Sentinel resolution runs HERE - after the alias reconciliation + # above (time_col is now the resolved column name) - so both the + # canonical time= and legacy time_col= spellings reach the + # inference helper resolved (M-027). + 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}." ) @@ -3170,12 +3322,12 @@ def fit( if trends_lin: if aggregate != "event_study": raise NotImplementedError( - "HAD.fit(trends_lin=True) requires " - "aggregate='event_study' (the linear-trend slope " - "estimator needs Y at F-2, which a 2-period panel " - "does not contain). Pass a panel with at least 3 " - "periods and aggregate='event_study'; the per-" - "horizon arrays in the resulting " + "HAD.fit(trends_lin=True) requires a multi-period " + "panel (the linear-trend slope estimator needs Y at " + "F-2, which a 2-period panel does not contain). " + "Pass a panel with at least 3 periods - fit() then " + "selects the event-study mode - and the per-horizon " + "arrays in the resulting " "HeterogeneousAdoptionDiDEventStudyResults provide " "the same single-effect / per-effect estimates as " "the overall path." @@ -4333,7 +4485,7 @@ def _fit_event_study( # survey path composes Binder-TSL variance and would silently override # the cluster-robust sandwich while metadata still reports the # cluster-robust vcov. Reject it BEFORE extracting the column (mirrors - # the static-path guard had.py:3361) so the error is predictable even + # the static-path guard had.py:3371) so the error is predictable even # for a malformed cluster column. The clustered band reconciles the # variance family (raw cluster Rademacher; mass-point sqrt(G/(G-1)) # CR1 scaling). @@ -4438,7 +4590,7 @@ def _fit_event_study( vcov_requested = "" inference_method = "analytical_nonparametric" # cluster-robust CCT SE when cluster= is set (Phase 2a static-path - # parity, had.py:3615); labelled "cr1" for surface consistency + # parity, had.py:3766); labelled "cr1" for surface consistency # with the mass-point CR1 path. vcov_label = "cr1" if cluster_arg is not None else None cluster_label = cluster_arg if cluster_arg is not None else None diff --git a/diff_diff/had_pretests.py b/diff_diff/had_pretests.py index 46d4a1ca..b815e523 100644 --- a/diff_diff/had_pretests.py +++ b/diff_diff/had_pretests.py @@ -34,15 +34,16 @@ Composite workflow: -:func:`did_had_pretest_workflow` has two dispatch modes: +:func:`did_had_pretest_workflow` has two dispatch modes, selected from +the panel shape (the deprecated ``aggregate=`` override is row M-139): -- ``aggregate="overall"`` (default, two-period panel): runs steps 1 + 3 +- The "overall" battery (two-period panel): runs steps 1 + 3 via :func:`qug_test` + :func:`stute_test` + :func:`yatchew_hr_test`. Paper step 2 is NOT run on this path (a two-period panel has no pre- period placebo); the verdict explicitly flags the Assumption 7 gap via the ``"paper step 2 deferred"`` caveat so callers do not get an unconditional "TWFE safe" signal. -- ``aggregate="event_study"`` (multi-period panel, >= 3 periods): runs +- The "event_study" battery (multi-period panel, >= 3 periods): runs QUG at ``F`` + joint pre-trends Stute across earlier pre-periods + joint homogeneity-linearity Stute across post-periods. Closes the paper step-2 gap and does NOT emit the step-2-deferred caveat in the @@ -85,6 +86,7 @@ _aggregate_first_difference, _aggregate_unit_resolved_survey, _aggregate_unit_weights, + _infer_aggregate_mode, _json_safe_scalar, _validate_had_panel, _validate_had_panel_event_study, @@ -1170,7 +1172,7 @@ def _has_lonely_psu_adjust_singletons(resolved: Any) -> bool: while the analytical variance target requires a pseudo-stratum centering transform that is not derived for the Stute CvM (Phase 4.5 C R5 P1; mirrors the explicit lonely-PSU reject on - HeterogeneousAdoptionDiD's sup-t bootstrap at ``had.py:2081-2118``). + HeterogeneousAdoptionDiD's sup-t bootstrap at ``had.py:2194-2231``). """ if getattr(resolved, "lonely_psu", "remove") != "adjust": return False @@ -1898,7 +1900,7 @@ def stute_test( # § "Note (Stute stratified survey-bootstrap calibration)" and # ``apply_stratum_centering`` (bootstrap_utils.py) for the # derivation; the same helper backs the HAD sup-t event-study - # bootstrap at had.py:2151+. + # bootstrap at had.py:2264+. # R5 P1: reject lonely_psu='adjust' singleton-strata designs. # This pseudo-stratum centering transform has not been derived # for the Stute CvM (same gap as the HAD sup-t deviation at @@ -1951,7 +1953,7 @@ def stute_test( ) # Stratum centering + Bessel rescale on the PSU multipliers # before broadcast. Same algebra as the HAD sup-t bootstrap at - # had.py:2151+ (applied to the influence tensor there), but + # had.py:2264+ (applied to the influence tensor there), but # applied here to ``psu_mults`` because the Stute bootstrap is a # wild-residual / refit-in-loop bootstrap (no precomputed # influence tensor exists). See REGISTRY § "Note (Stute @@ -3346,7 +3348,7 @@ def _resolve_pretest_unit_weights( ) # R1 P1: pweight-only guard. aweight/fweight slip through pweight-only # formulas silently otherwise (mirrors HeterogeneousAdoptionDiD.fit() at - # had.py:2976+ and survey._resolve_pweight_only at survey.py:914). + # had.py:3098+ and survey._resolve_pweight_only at survey.py:914). if getattr(resolved_full, "weight_type", "pweight") != "pweight": raise ValueError( f"{caller_name}: HAD pretests require weight_type='pweight'. " @@ -4256,7 +4258,7 @@ def did_had_pretest_workflow( n_bootstrap: int = 999, seed: Optional[int] = None, *, - aggregate: str = "overall", + aggregate: Any = NOT_SUPPLIED, survey_design: Any = None, trends_lin: bool = False, outcome_col: Any = NOT_SUPPLIED, @@ -4267,18 +4269,19 @@ def did_had_pretest_workflow( ) -> HADPretestReport: """Run the HAD pre-test workflow (paper Section 4.2-4.3). - Two dispatch modes via ``aggregate``: + Two dispatch modes, selected from the panel shape (row M-139; the + deprecated ``aggregate=`` override warns and dies in 4.0): - ``aggregate="overall"`` (default, two-period panel): runs paper + The overall battery (two-period panel): runs paper steps 1 (:func:`qug_test`) and 3 (:func:`stute_test` + :func:`yatchew_hr_test`). Step 2 (Assumption 7 pre-trends) is NOT implemented on this path because a single-pre-period panel cannot support the joint Stute variant; the returned verdict flags the Assumption 7 gap explicitly so callers do not receive a misleading - "TWFE safe" signal. For multi-period panels, pass - ``aggregate="event_study"`` to close the step-2 gap. + "TWFE safe" signal. Supplying a multi-period panel closes the + step-2 gap. - ``aggregate="event_study"`` (multi-period panel, >= 3 periods): runs + The event-study battery (multi-period panel, >= 3 periods): runs QUG + joint pre-trends Stute + joint homogeneity-linearity Stute, covering paper Section 4 steps 1-3 together. The step-3 Yatchew-HR alternative (a single-horizon swap-in for Stute) is subsumed by joint @@ -4325,8 +4328,13 @@ def did_had_pretest_workflow( seed : int or None, default None Seed forwarded to the Stute bootstrap. QUG / Yatchew are deterministic. - aggregate : str, keyword-only, default ``"overall"`` - Dispatch mode. Invalid values raise ``ValueError``. + 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) @@ -4338,10 +4346,9 @@ def did_had_pretest_workflow( Forwards into :func:`joint_pretrends_test` and :func:`joint_homogeneity_test` on the event-study dispatch path. Mirrors R ``DIDHAD::did_had(..., trends_lin=TRUE)``. - Requires ``aggregate="event_study"``; raises - ``NotImplementedError`` on ``aggregate="overall"`` (the - overall path's qug + stute + yatchew block has no - joint-pretest surface). Mutually exclusive with survey + Requires a multi-period panel (the event-study battery); raises + ``NotImplementedError`` on the two-period overall path (its + qug + stute + yatchew block has no joint-pretest surface). Mutually exclusive with survey weighting at the joint-pretest layer; the joint wrappers raise ``NotImplementedError`` if combined. **Effective step-2 rule under trends_lin**: the consumed placebo at @@ -4439,6 +4446,23 @@ def did_had_pretest_workflow( D, Theorem 7. """ _q = "did_had_pretest_workflow" + # 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 - AFTER the + # time/time_col alias reconciliation below - at the + # _VALID_AGGREGATES check site. + 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, + ) outcome = resolve_renamed_kwarg( _q, "outcome_col", outcome_col, "outcome", outcome, default=NOT_SUPPLIED ) @@ -4458,7 +4482,12 @@ def did_had_pretest_workflow( time_col = time unit_col = unit first_treat_col = first_treat - if aggregate not in _VALID_AGGREGATES: + # Sentinel resolution runs HERE - after the alias reconciliation + # above, so both time= and legacy time_col= spellings reach the + # inference helper resolved (M-139, mirroring fit's M-027 shim). + 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}." ) @@ -4471,12 +4500,12 @@ def did_had_pretest_workflow( # rather than silently ignore. if trends_lin and aggregate != "event_study": raise NotImplementedError( - "did_had_pretest_workflow(trends_lin=True) requires " - "aggregate='event_study' (the trends_lin kwarg forwards " - "into the joint pretests, which only run on the event-" - "study path). The overall path's qug + stute + yatchew " - "block has no per-group slope surface; pass a multi-" - "period panel and aggregate='event_study'." + "did_had_pretest_workflow(trends_lin=True) requires a " + "multi-period panel (the trends_lin kwarg forwards into " + "the joint pretests, which only run on the event-study " + "path selected for panels with more than two periods). " + "The overall path's qug + stute + yatchew block has no " + "per-group slope surface; pass a multi-period panel." ) # R6 P1 fix: do NOT call _resolve_pretest_unit_weights on the FULL panel diff --git a/diff_diff/honest_did.py b/diff_diff/honest_did.py index 45bea41f..8e746c80 100644 --- a/diff_diff/honest_did.py +++ b/diff_diff/honest_did.py @@ -621,7 +621,17 @@ def _extract_container_params( "covariance, but the pre-period coefficients are stage-1 " "residual means, not contrasts against a reference period as " "HonestDiD's delta_0=0 arithmetic requires (see the REGISTRY " - "TwoStageDiD Note and DEFERRED.md)." + "TwoStageDiD Note and DEFERRED.md); " + "HeterogeneousAdoptionDiDEventStudyResults container " + "admission is DEFERRED pending joint cross-horizon " + "covariance (per-horizon independent sandwiches only; " + "DEFERRED.md) - its coefficients ARE reference-normalized " + "(each horizon differences against the F-1 anchor), but the " + "anchor row itself is omitted from the container (its " + "coefficient is identically zero and the WAS is not " + "identified there), so no reference row exists for the " + "consumer grid (see the REGISTRY HeterogeneousAdoptionDiD " + "Note)." ) _producer = surface.source.replace("Results", "") if surface.time_scale != "relative": diff --git a/diff_diff/imputation.py b/diff_diff/imputation.py index 2d06b2f7..28ac432c 100644 --- a/diff_diff/imputation.py +++ b/diff_diff/imputation.py @@ -1367,7 +1367,7 @@ def _build_imputation_aggregation_kit( alpha=alpha, anticipation=anticipation, cband=False, # no simultaneous-band concept on this estimator - bootstrap=None, # replay not wired; results.aggregate() fails closed + bootstrap=None, # replay not wired; recompute levels fail closed ('simple' relays, M-027) ) diff --git a/diff_diff/imputation_results.py b/diff_diff/imputation_results.py index f3273ea7..d38b2f58 100644 --- a/diff_diff/imputation_results.py +++ b/diff_diff/imputation_results.py @@ -241,18 +241,26 @@ def _aggregate_compute( "an older release will not have one. Re-fit with " "diff-diff >= 3.9 to aggregate post-fit." ) + # Per-level bootstrap policy (v4-design section 6, converged with row + # M-027): 'simple' is a bit-exact RELAY of the stored overall quintet + # - faithful under any inference regime, bootstrap included - so it + # dispatches BEFORE the bootstrap gate. Only the RECOMPUTE levels + # below fail closed on bootstrapped fits. (This supersedes the + # uniform-conservatism decision recorded with M-021; its rationale is + # honored by the relay's NaN df column.) + if level == "simple": + return self._aggregate_simple_result(kit) if self.bootstrap_results is not None: raise NotImplementedError( - "aggregate() is not yet available on a bootstrapped fit " - "(n_bootstrap > 0): the per-target bootstrap draws are not " - "retained, so post-fit re-aggregation cannot replay " + f"aggregate({level!r}) is not yet available on a bootstrapped " + "fit (n_bootstrap > 0): the per-target bootstrap draws are " + "not retained, so post-fit re-aggregation cannot replay " "percentile inference and analytical inference would " - "misrepresent the fit. Re-fit with the aggregation you " - "need, or use n_bootstrap=0." + "misrepresent the fit. aggregate('simple') relays the stored " + "bootstrap inference and remains available; otherwise re-fit " + "with the aggregation you need, or use n_bootstrap=0." ) bk = dict(kit.bookkeeping) - if level == "simple": - return self._aggregate_simple_result(kit) if level == "event_study" and bk["uses_replicate"] and bk["pretrends"]: # The same unsupported combination fit(aggregate='event_study') # rejects: the pre-period lead regression's per-replicate refits @@ -385,7 +393,9 @@ def _aggregate_simple_result(self, kit: Any) -> AggregationResult: that value came from the ``[overall]``-only joint stack, which is precisely why it must be snapshotted rather than re-derived). None → all-NaN df column; the replicate-undefined 0 sentinel NaNs - out via post_init. + out via post_init. Bootstrapped fits relay the stored quintet + verbatim with a NaN df column - no df governs percentile + inference (the per-level policy converged with row M-027). """ return AggregationResult( level="simple", @@ -398,7 +408,9 @@ def _aggregate_simple_result(self, kit: Any) -> AggregationResult: conf_int_lower=np.array([self.overall_conf_int[0]], dtype=float), conf_int_upper=np.array([self.overall_conf_int[1]], dtype=float), n=np.array([kit.bookkeeping["n_treated_obs"]], dtype=float), - df=kit.bookkeeping["survey_df_final"], + df=( + np.nan if self.bootstrap_results is not None else kit.bookkeeping["survey_df_final"] + ), alpha=kit.alpha, n_kind="obs", weight=np.array([1.0], dtype=float), diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index 0d6897a2..5fefc207 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -221,7 +221,7 @@ def _parallel_trends_step(staggered: bool = False) -> Dict[str, Any]: "coefficients rather than the generic check_parallel_trends() " "which assumes a single binary treatment with universal " "pre-periods. Pre-treatment ATTs should be near zero. " - "Use CS with aggregate='event_study' or check the estimator's " + "Use CS post-fit results.aggregate('event_study') or check the estimator's " "event-study output directly." ), code=( @@ -378,22 +378,25 @@ def _handle_multi_period(results: Any): def _handle_cs(results: Any): - # Post-fit aggregate() RAISES on a bootstrapped fit (percentile - # statistics are not retained for re-aggregation), so the guidance - # must route those fits through the retained fit-time aggregation - # instead of advice that cannot run. + # The post-fit RECOMPUTE levels raise on a bootstrapped fit + # (percentile statistics are not retained for re-aggregation; + # 'simple' relays the stored quintet and stays available), so the + # event-study guidance must route those fits through the retained + # fit-time aggregation instead of advice that cannot run. is_bootstrap = getattr(results, "bootstrap_results", None) is not None if is_bootstrap: sensitivity_why = ( "Bounds the treatment effect under plausible violations of " - "parallel trends. This fit is BOOTSTRAPPED, and post-fit " - "aggregate() raises on bootstrap fits - refit with the " - "fit-time aggregation to populate the event-study surface." + "parallel trends. This fit is BOOTSTRAPPED, and the post-fit " + "event-study/group recompute levels raise on bootstrap fits " + "(aggregate('simple') still relays the stored inference) - " + "refit with the fit-time aggregation to populate the " + "event-study surface." ) sensitivity_code = ( "from diff_diff import compute_honest_did\n" - "# Bootstrap fit: post-fit aggregate() raises - use the\n" - "# fit-time aggregation:\n" + "# Bootstrap fit: the post-fit ES recompute raises - use the\n" + "# fit-time aggregation for the event-study surface:\n" "results = cs.fit(data, ..., aggregate='event_study')\n" "honest = compute_honest_did(results, method='relative_magnitude', M=1.0)\n" "print(honest.summary())" @@ -506,10 +509,11 @@ def _handle_imputation(results: Any): "ImputationDiD aggregates post-fit from its panel-backed kit " "(M-021) - no refit needed." if getattr(results, "bootstrap_results", None) is None - else "This fit is BOOTSTRAPPED, and post-fit aggregate() " - "raises on bootstrap fits - refit with the deprecated " - "fit-time aggregation (or n_bootstrap=0) to obtain the " - "aggregated surfaces." + else "This fit is BOOTSTRAPPED: the post-fit event-study/group " + "recompute levels raise on bootstrap fits, while " + "aggregate('simple') relays the stored inference - " + "refit with the deprecated fit-time aggregation (or " + "n_bootstrap=0) to obtain the recomputed surfaces." ), code=( "# Aggregate post-fit - no refit needed:\n" @@ -563,10 +567,11 @@ def _handle_two_stage(results: Any): "TwoStageDiD aggregates post-fit from its panel-backed kit " "(M-022) - no refit needed." if getattr(results, "bootstrap_results", None) is None - else "This fit is BOOTSTRAPPED, and post-fit aggregate() " - "raises on bootstrap fits - refit with the deprecated " - "fit-time aggregation (or n_bootstrap=0) to obtain the " - "aggregated surfaces." + else "This fit is BOOTSTRAPPED: the post-fit event-study/group " + "recompute levels raise on bootstrap fits, while " + "aggregate('simple') relays the stored inference - " + "refit with the deprecated fit-time aggregation (or " + "n_bootstrap=0) to obtain the recomputed surfaces." ), code=( "# Aggregate post-fit - no refit needed:\n" @@ -954,10 +959,11 @@ def _handle_efficient(results: Any): why=( "EfficientDiD aggregates post-fit from retained EIFs " "(M-023) - no refit needed." if getattr(results, "bootstrap_results", None) is None - else "This fit is BOOTSTRAPPED, and post-fit aggregate() " - "raises on bootstrap fits - refit with the deprecated " - "fit-time aggregation (or n_bootstrap=0) to obtain the " - "aggregated surfaces." + else "This fit is BOOTSTRAPPED: the post-fit event-study/group " + "recompute levels raise on bootstrap fits, while " + "aggregate('simple') relays the stored inference - " + "refit with the deprecated fit-time aggregation (or " + "n_bootstrap=0) to obtain the recomputed surfaces." ), code=( "# Aggregate post-fit - no refit needed:\n" @@ -1013,9 +1019,9 @@ def _handle_continuous(results: Any): "HeterogeneousAdoptionDiD instead. HAD identifies a Weighted " "Average Slope (WAS) at the dose support boundary by leveraging " "dose variation across units. HAD's contract is panel-shape " - "dependent: `aggregate='overall'` (the default) is two-period " + "dependent - and fit() selects the mode from it (M-027): the overall (single-period WAS) estimator is two-period " "only and hard-rejects multi-period panels at fit time; " - "multi-period panels MUST set `aggregate='event_study'`. " + "multi-period panels fit the per-horizon event-study estimator. " "Additionally, on staggered (multi-cohort) panels the event-" "study path auto-filters to the LAST treatment cohort + never-" "treated units (paper Appendix B.2) and the estimand becomes " @@ -1048,12 +1054,11 @@ def _handle_continuous(results: Any): " data_had_2p, outcome='y', unit='unit',\n" " time='t', dose='d', first_treat='first_treat')\n" "\n" - "# Multi-period panel: must set aggregate='event_study'\n" + "# Multi-period panel: fit() selects the event-study mode\n" "# (on staggered panels this is auto-last-cohort-only WAS)\n" "had_es = had.fit(\n" " data_had_mp, outcome='y', unit='unit',\n" - " time='t', dose='d', first_treat='first_treat',\n" - " aggregate='event_study')" + " time='t', dose='d', first_treat='first_treat')" ), step_name="estimator_selection", ), @@ -1200,7 +1205,7 @@ def _handle_had(results: Any): "path - a single pre-period cannot support the joint " "Stute variant - and the returned verdict explicitly " "flags that gap. To close step 2, refit on a multi-period " - "panel with aggregate='event_study' AND verify the panel " + "panel (the workflow then runs the event-study battery) AND verify the panel " "has at least one earlier placebo pre-period beyond F-1; " "if only the base pre-period F-1 is available, the " "workflow still sets pretrends_joint=None, all_pass=False, " @@ -1231,7 +1236,7 @@ def _handle_had(results: Any): " first_treat='first_treat')\n" "print(report.summary())\n" "# verdict explicitly flags the Assumption 7 gap on the\n" - "# overall path; aggregate='event_study' on a multi-period\n" + "# overall path; a multi-period panel (the event-study battery)\n" "# panel adds joint Stute pre-trends + joint homogeneity-linearity.\n" "# Passing survey_design= / weights= skips QUG (Phase 4.5 C0)\n" "# and returns a linearity-conditional verdict only." @@ -1307,30 +1312,30 @@ def _handle_had(results: Any): ), _step( baker_step=7, - label="Re-fit with aggregate='event_study' for per-horizon WAS", + label="Re-fit on a multi-period panel for per-horizon WAS", why=( - "On multi-period panels, the event-study aggregate returns " + "On multi-period panels, the event-study mode returns " "per-event-time WAS estimates instead of a single scalar. " "Reveals whether dose response grows, decays, or stabilizes " "across post-treatment horizons. Pre-period placebos serve " "as a parallel-trends sanity check. NOTE: this handler is " "the single-period HAD handler, so the upstream fit was " - "two-period-only (`aggregate='overall'` hard-rejects more " - "than two periods at `had.py:980-987`). Use a distinct " - "multi-period panel `data_mp` for this step - the same " - "panel that the upstream fit ran on will not satisfy " - "the event-study path's period-count requirement." + "two-period-only (the overall estimator hard-rejects more " + "than two periods; fit() selects the mode from the panel " + "shape, M-027). Use a distinct multi-period panel " + "`data_mp` for this step - the same panel that the " + "upstream fit ran on will not satisfy the event-study " + "path's period-count requirement." ), code=( "from diff_diff import HeterogeneousAdoptionDiD\n" "# Requires a distinct multi-period panel - the upstream\n" - "# two-period panel was already consumed by `aggregate='overall'`.\n" + "# two-period panel already fit the overall (single-period) mode.\n" "est = HeterogeneousAdoptionDiD()\n" "es = est.fit(\n" " data_mp, outcome='y', unit='unit',\n" " time='t', dose='d',\n" - " first_treat='first_treat',\n" - " aggregate='event_study')" + " first_treat='first_treat')" ), priority="medium", step_name="heterogeneity", @@ -1382,7 +1387,8 @@ def _handle_had_event_study(results: Any): label="Run the HAD pretest battery (event-study mode)", why=( "On multi-period unweighted panels, did_had_pretest_workflow " - "with aggregate='event_study' runs QUG plus joint Stute " + "runs the event-study battery (selected from the panel " + "shape, M-139): QUG plus joint Stute " "pre-trends plus joint homogeneity-linearity Stute. The " "joint Stute pre-trends variant closes the paper Section " "4.2 step-2 gap ONLY IF the panel carries at least one " @@ -1414,8 +1420,7 @@ def _handle_had_event_study(results: Any): "report = did_had_pretest_workflow(\n" " data, outcome='y', unit='unit',\n" " time='t', dose='d',\n" - " first_treat='first_treat',\n" - " aggregate='event_study')\n" + " first_treat='first_treat')\n" "print(report.summary())" ), step_name="parallel_trends", @@ -1494,7 +1499,7 @@ def _handle_had_event_study(results: Any): "# survey_design= (the default classical sandwich raises\n" "# NotImplementedError on the survey path because the\n" "# Binder-TSL composition consumes the HC1-scale IF -\n" - "# see had.py:3495-3507). On the continuous designs the\n" + "# see had.py:3646-3658). On the continuous designs the\n" "# vcov_type kwarg is unused (CCT-2014 robust SE is the\n" "# only formula), so passing vcov_type='hc1' is a no-op\n" "# there and a safe default for the survey-aware example.\n" @@ -1504,7 +1509,6 @@ def _handle_had_event_study(results: Any): " data, outcome='y', unit='unit',\n" " time='t', dose='d',\n" " first_treat='first_treat',\n" - " aggregate='event_study',\n" " survey_design=sd, cband=True)\n" "es.cband_low, es.cband_high # simultaneous band endpoints" ), diff --git a/diff_diff/pretrends.py b/diff_diff/pretrends.py index e517f77b..99b462e0 100644 --- a/diff_diff/pretrends.py +++ b/diff_diff/pretrends.py @@ -1458,7 +1458,17 @@ def _extract_container_pre_period_params( "- analytical fits carry the joint Gardner-GMM covariance, " "but the pre-period coefficients are stage-1 residual " "means, not contrasts against a reference period (see the " - "REGISTRY TwoStageDiD Note and DEFERRED.md)." + "REGISTRY TwoStageDiD Note and DEFERRED.md); " + "HeterogeneousAdoptionDiDEventStudyResults container " + "admission is DEFERRED pending joint cross-horizon " + "covariance (per-horizon independent sandwiches only; " + "DEFERRED.md) - its coefficients ARE reference-normalized " + "(each horizon differences against the F-1 anchor), but " + "the anchor row itself is omitted from the container (its " + "coefficient is identically zero and the WAS is not " + "identified there), so no reference row exists for the " + "consumer grid (see the REGISTRY " + "HeterogeneousAdoptionDiD Note)." ) _producer = surface.source.replace("Results", "") if surface.time_scale != "relative": diff --git a/diff_diff/profile.py b/diff_diff/profile.py index 33f5db30..7e2d0748 100644 --- a/diff_diff/profile.py +++ b/diff_diff/profile.py @@ -125,7 +125,7 @@ class TreatmentDoseShape: continuous covariate is another. - When (5) fails (negative treated doses): ``HeterogeneousAdoptionDiD`` is **not** a fallback either — - HAD raises on negative post-period dose (``had.py:1450-1459``, + HAD raises on negative post-period dose (``had.py:1563-1572``, paper Section 2). Linear DiD with the treatment as a signed continuous covariate is the applicable routing alternative. - Re-encoding the treatment column (shifting, absolute value, diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index af1c3d0c..48523ef3 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -127,6 +127,11 @@ def _json_safe_label(value: Any) -> Any: "n", "df", "is_reference", + # The per-row estimand discriminator (appended with row M-027, the + # AggregationResult.target precedent): "att" for every ATT producer, + # the estimand label ("WAS"/"WAS_d_lower") where the att column is + # NOT an ATT - so a detached frame never mislabels its numbers. + "estimand", ) #: Closed vocabulary for the ``n_kind`` field, SHARED by every container that @@ -313,6 +318,15 @@ class EventStudyResults(BaseResults): # against more than one base. None when the producer records no such # notion (varying base, non-CS producers, hand-built surfaces). reference_event_times: Optional[Tuple[Any, ...]] = None + #: The estimand label for the ``att`` column when the producer's + #: per-horizon estimand is NOT an ATT - HeterogeneousAdoptionDiD's + #: per-horizon "WAS"/"WAS_d_lower" (row M-027; ``_from_had`` relays + #: ``target_parameter``). None means the column is an ATT (every other + #: producer), keeping their rendering byte-stable. ``summary()`` uses + #: it as the column heading and ``to_dict()`` serializes it, so the + #: numbers never silently change meaning. Optional provenance appended + #: last (the M-092 pre-cut amendment convention). + estimand: Optional[str] = None _ARRAY_FIELDS = ( "att", @@ -500,6 +514,9 @@ def to_dataframe(self) -> pd.DataFrame: # scalar-accepting field type for mypy. "df": cast(np.ndarray, self.df), "is_reference": self.is_reference, + # Per-row estimand discriminator: a detached frame must not + # mislabel WAS-family numbers as ATT (row M-027). + "estimand": np.full(n_rows, self.estimand or "att", dtype=object), }, columns=list(EVENT_STUDY_SCHEMA), ) @@ -543,6 +560,7 @@ def to_dict(self) -> Dict[str, Any]: "df": cast(np.ndarray, self.df).tolist(), "base_period": self.base_period, "anticipation": self.anticipation, + "estimand": self.estimand, "df_survey": self.df_survey, "reference_event_times": ( # _json_safe_label per element: CS period arithmetic yields @@ -585,19 +603,26 @@ def summary(self, alpha: Optional[float] = None) -> str: meta_bits.append(f"convention: {self.event_time_convention}") if self.n_kind: meta_bits.append(f"n counts: {self.n_kind}") + if self.estimand: + # A non-ATT per-horizon estimand (HAD's WAS family, M-027): + # name it in the metadata and use it as the column heading - + # the hard-coded ATT would silently relabel the numbers. + meta_bits.append(f"estimand: {self.estimand}") lines.append(" ".join(meta_bits)) lines.append("-" * 78) + est_head = self.estimand or "ATT" + est_w = max(10, len(est_head)) lines.append( - f"{'Event time':>12} {'ATT':>10} {'SE':>10} {'t':>8} " + f"{'Event time':>12} {est_head:>{est_w}} {'SE':>10} {'t':>8} " f"{'P>|t|':>8} {f'[{ci_pct}% CI]':>21}" ) for i in range(self.event_time.shape[0]): label = f"{self.event_time[i]}" if self.is_reference[i]: - lines.append(f"{label:>12} {0.0:>10.4f} {'(reference)':>{50}}") + lines.append(f"{label:>12} {0.0:>{est_w}.4f} {'(reference)':>{50}}") continue lines.append( - f"{label:>12} {self.att[i]:>10.4f} {self.se[i]:>10.4f} " + f"{label:>12} {self.att[i]:>{est_w}.4f} {self.se[i]:>10.4f} " f"{self.t_stat[i]:>8.3f} {self.p_value[i]:>8.3f} " f"[{self.conf_int_lower[i]:>9.4f}, {self.conf_int_upper[i]:>9.4f}]" ) @@ -1207,8 +1232,13 @@ def _from_had(results: Any) -> EventStudyResults: conf_int_lower=np.array(results.conf_int_low, dtype=float), conf_int_upper=np.array(results.conf_int_high, dtype=float), is_reference=np.zeros(n_rows, dtype=bool), + # "units", not "obs": ``n_obs_per_horizon`` counts the UNITS + # contributing at each event time (it equals ``n_units`` at every + # horizon under the no-NaN validator) - the field docstring says + # so, and N_KIND_VOCABULARY forbids conflating the two kinds. + # Corrected with row M-027 (the value previously read "obs"). n=(np.asarray(n_obs, dtype=float) if n_obs is not None else np.full(n_rows, np.nan)), - n_kind="obs" if n_obs is not None else None, + n_kind="units" if n_obs is not None else None, time_scale="relative", event_time_convention="e0_first_treated", cband_lower=np.asarray(cband_lo, dtype=float) if cband_lo is not None else None, @@ -1216,6 +1246,9 @@ def _from_had(results: Any) -> EventStudyResults: cband_crit_value=getattr(results, "cband_crit_value", None), alpha=getattr(results, "alpha", 0.05), source=type(results).__name__, + # The per-horizon estimand is a WAS, not an ATT (row M-027): + # relay the estimand label so summary()/to_dict never mislabel. + estimand=getattr(results, "target_parameter", None), **_provenance_kwargs(results), ) diff --git a/diff_diff/staggered.py b/diff_diff/staggered.py index 0370a2c0..20e32f8a 100644 --- a/diff_diff/staggered.py +++ b/diff_diff/staggered.py @@ -1864,11 +1864,14 @@ def fit( ``plot_event_study`` all accept the post-fit container from ``results.aggregate('event_study')`` directly, so no consumer requires the fit-time surface anymore - EXCEPT on bootstrapped - fits (``n_bootstrap > 0``), where post-fit ``aggregate()`` - raises (percentile inference cannot be reproduced from the - retained analytical state) and fit-time aggregation remains - the supported route. Otherwise it remains only as the - deprecated compatibility path through 3.9. + fits (``n_bootstrap > 0``), where the post-fit RECOMPUTE + levels (``'event_study'``/``'group'``) raise (percentile + inference cannot be reproduced from the retained analytical + state; ``aggregate('simple')`` relays the stored bootstrap + inference and stays available) and fit-time aggregation + remains the supported route for those levels. Otherwise it + 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 @@ -5149,10 +5152,13 @@ def _build_aggregation_kit( alpha=estimator.alpha, anticipation=estimator.anticipation, cband=bool(estimator.cband), - # Bootstrap replay is not wired in this PR: a bootstrapped fit's - # percentile inference cannot be reproduced from analytical state, so - # aggregate() fails closed on one rather than silently substituting - # analytical numbers. BootstrapReplaySpec (diff_diff/aggregation.py) is - # the verified mechanism for the follow-up. + # Bootstrap replay is not wired: a bootstrapped fit's percentile + # inference cannot be reproduced from analytical state, so the + # RECOMPUTE levels (event_study/group) fail closed on one rather than + # silently substituting analytical numbers ('simple' relays the + # stored bootstrap inference and stays available - the per-level + # policy converged with row M-027). BootstrapReplaySpec + # (diff_diff/aggregation.py) is the verified mechanism for the + # follow-up. bootstrap=None, ) diff --git a/diff_diff/staggered_results.py b/diff_diff/staggered_results.py index 3a8e96d5..39f2407f 100644 --- a/diff_diff/staggered_results.py +++ b/diff_diff/staggered_results.py @@ -309,16 +309,25 @@ def _aggregate_compute( "re-aggregated. Kits are attached by CallawaySantAnna.fit(); a " "result unpickled from an older release will not have one." ) + # Per-level bootstrap policy (v4-design section 6, converged with row + # M-027): 'simple' is a bit-exact RELAY of the stored overall + # inference - faithful under any inference regime, bootstrap included + # - so it dispatches BEFORE the bootstrap gate. Only the RECOMPUTE + # levels below fail closed on bootstrapped fits. + if level == "simple": + return self._aggregate_simple_result(kit) if self.bootstrap_results is not None: # Fail closed rather than silently handing back analytical numbers: # a bootstrapped fit's se/p/CI are percentile statistics, and # reproducing them post-fit needs retained draws (BootstrapReplaySpec). raise NotImplementedError( - "aggregate() is not yet available on a bootstrapped fit " - "(n_bootstrap > 0): its inference is percentile-bootstrap based " - "and cannot be reproduced from the analytical state retained " - "here. Re-fit with the aggregation you need, or use " - "n_bootstrap=0 for analytical inference." + f"aggregate({level!r}) is not yet available on a bootstrapped " + "fit (n_bootstrap > 0): its inference is percentile-bootstrap " + "based and cannot be reproduced from the analytical state " + "retained here. aggregate('simple') relays the stored " + "bootstrap inference and remains available; otherwise re-fit " + "with the aggregation you need, or use n_bootstrap=0 for " + "analytical inference." ) # Shallow copy: shares every array (no data is duplicated) but gives the @@ -328,8 +337,6 @@ def _aggregate_compute( precomputed = dict(kit.bookkeeping) agg = _KitAggregator(kit.alpha, kit.anticipation) - if level == "simple": - return self._aggregate_simple_result(kit) if level == "group": effects = agg._aggregate_by_group( self.group_time_effects, @@ -374,7 +381,12 @@ def _aggregate_simple_result(self, kit: Any) -> AggregationResult: ``_aggregate_simple`` runs unconditionally in ``fit()``, so the numbers are already stored - this is a view, not a recomputation, and is - therefore bit-identical to the fit by construction. + therefore bit-identical to the fit by construction. That is also why + it is PERMITTED on bootstrapped fits (the per-level policy converged + with row M-027): the stored quintet - percentile se/p/CI beside the + finite ``safe_inference`` t - relays verbatim, and only the df COLUMN + is NaN'd there (no df governs percentile inference, so reporting the + analytical df beside percentile p would misstate provenance). """ # n_treated_units / n_control_units are UNITS on a panel fit but # OBSERVATIONS on a declared repeated cross-section, where fit() counts @@ -399,8 +411,9 @@ def _aggregate_simple_result(self, kit: Any) -> AggregationResult: # explicit ``survey_design=`` fits, where the df that actually # governed ``overall_p_value`` lives on ``survey_metadata``. # Reading it directly reported df=NaN for survey fits whose CI - # was built on a finite t-reference. - df=resolve_inference_df(self), + # was built on a finite t-reference. Bootstrapped fits report a + # NaN df column: no df governs percentile inference. + df=(np.nan if self.bootstrap_results is not None else resolve_inference_df(self)), alpha=self.alpha, n_kind=n_kind, weight=np.array([1.0], dtype=float), diff --git a/diff_diff/two_stage.py b/diff_diff/two_stage.py index 49c7ed8a..610ec3ad 100644 --- a/diff_diff/two_stage.py +++ b/diff_diff/two_stage.py @@ -2322,7 +2322,7 @@ def _build_twostage_aggregation_kit( alpha=alpha, anticipation=anticipation, cband=False, # no simultaneous-band concept on this estimator - bootstrap=None, # replay not wired; results.aggregate() fails closed + bootstrap=None, # replay not wired; recompute levels fail closed ('simple' relays, M-027) ) diff --git a/diff_diff/two_stage_results.py b/diff_diff/two_stage_results.py index c37d2eaf..e3c1125e 100644 --- a/diff_diff/two_stage_results.py +++ b/diff_diff/two_stage_results.py @@ -242,18 +242,26 @@ def _aggregate_compute( "an older release will not have one. Re-fit with " "diff-diff >= 3.9 to aggregate post-fit." ) + # Per-level bootstrap policy (v4-design section 6, converged with row + # M-027): 'simple' is a bit-exact RELAY of the stored overall quintet + # - faithful under any inference regime, bootstrap included - so it + # dispatches BEFORE the bootstrap gate. Only the RECOMPUTE levels + # below fail closed on bootstrapped fits. (This supersedes the + # uniform-conservatism decision recorded with M-022; its rationale is + # honored by the relay's NaN df column.) + if level == "simple": + return self._aggregate_simple_result(kit) if self.bootstrap_results is not None: raise NotImplementedError( - "aggregate() is not yet available on a bootstrapped fit " - "(n_bootstrap > 0): the per-target bootstrap draws are not " - "retained, so post-fit re-aggregation cannot replay " + f"aggregate({level!r}) is not yet available on a bootstrapped " + "fit (n_bootstrap > 0): the per-target bootstrap draws are " + "not retained, so post-fit re-aggregation cannot replay " "percentile inference and analytical inference would " - "misrepresent the fit. Re-fit with the aggregation you " - "need, or use n_bootstrap=0." + "misrepresent the fit. aggregate('simple') relays the stored " + "bootstrap inference and remains available; otherwise re-fit " + "with the aggregation you need, or use n_bootstrap=0." ) bk = dict(kit.bookkeeping) - if level == "simple": - return self._aggregate_simple_result(kit) # Fresh throwaway host per call, populated from KIT snapshots only # (estimator/config mutation after fit() must not leak in). agg = _TwoStageKitAggregator( @@ -390,7 +398,9 @@ def _aggregate_simple_result(self, kit: Any) -> AggregationResult: precisely why it must be snapshotted rather than re-derived: a post-fit level-matched replay produces a different n_valid). None → all-NaN df column; the replicate-undefined 0 sentinel NaNs - out via post_init. + out via post_init. Bootstrapped fits relay the stored quintet + verbatim with a NaN df column - no df governs percentile + inference (the per-level policy converged with row M-027). """ return AggregationResult( level="simple", @@ -403,7 +413,9 @@ def _aggregate_simple_result(self, kit: Any) -> AggregationResult: conf_int_lower=np.array([self.overall_conf_int[0]], dtype=float), conf_int_upper=np.array([self.overall_conf_int[1]], dtype=float), n=np.array([kit.bookkeeping["n_treated_obs"]], dtype=float), - df=kit.bookkeeping["survey_df_final"], + df=( + np.nan if self.bootstrap_results is not None else kit.bookkeeping["survey_df_final"] + ), alpha=kit.alpha, n_kind="obs", weight=np.array([1.0], dtype=float), diff --git a/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst b/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst index 09e0e2a9..7c97ea0e 100644 --- a/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst +++ b/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst @@ -12,6 +12,7 @@ .. autosummary:: ~HeterogeneousAdoptionDiDEventStudyResults.__init__ + ~HeterogeneousAdoptionDiDEventStudyResults.aggregate ~HeterogeneousAdoptionDiDEventStudyResults.print_summary ~HeterogeneousAdoptionDiDEventStudyResults.summary ~HeterogeneousAdoptionDiDEventStudyResults.to_dataframe diff --git a/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDResults.rst b/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDResults.rst index f3907b05..694440eb 100644 --- a/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDResults.rst @@ -12,6 +12,7 @@ .. autosummary:: ~HeterogeneousAdoptionDiDResults.__init__ + ~HeterogeneousAdoptionDiDResults.aggregate ~HeterogeneousAdoptionDiDResults.print_summary ~HeterogeneousAdoptionDiDResults.summary ~HeterogeneousAdoptionDiDResults.to_dataframe diff --git a/docs/api/had.rst b/docs/api/had.rst index 831b45f4..f5745964 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -105,7 +105,7 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: (unweighted cluster-robust), or route clustering through ``survey_design=SurveyDesign(weights='', psu='')``. All other ``cluster=`` compositions are supported end-to-end, including the - ``cluster=`` + ``aggregate="event_study"`` + ``cband=True`` mass-point + ``cluster=`` + event-study-mode + ``cband=True`` mass-point path: the clustered sup-t band draws cluster-level multipliers on the per-unit influence function and normalizes by the CR1 analytical SE (variance families reconciled via the ``√(G/(G-1))`` CR1 scalar). @@ -160,11 +160,11 @@ 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 explicit modes selected by the caller via the ``aggregate=`` kwarg: -``aggregate="overall"`` (default, two-period first-differenced sample) runs -single-period tests; ``aggregate="event_study"`` (multi-period panel with -three or more periods) runs joint multi-period tests. Both modes return a -unified :class:`~diff_diff.HADPretestReport`. +two modes selected from the panel shape (row M-139; the deprecated +``aggregate=`` override warns and dies in 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`. .. autofunction:: diff_diff.did_had_pretest_workflow @@ -174,8 +174,8 @@ unified :class:`~diff_diff.HADPretestReport`. :undoc-members: :show-inheritance: -Single-period tests (``aggregate="overall"``) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Single-period tests (two-period panels) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. autofunction:: diff_diff.qug_test @@ -201,8 +201,8 @@ Single-period tests (``aggregate="overall"``) :undoc-members: :show-inheritance: -Joint multi-period tests (``aggregate="event_study"``) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Joint multi-period tests (multi-period panels) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. autofunction:: diff_diff.stute_joint_pretest diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 2623bf7e..d7416879 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -429,13 +429,11 @@ before estimation; see :doc:`api/had` for the full API and SE-regime contract. had_data = pd.DataFrame(rows) pretests = did_had_pretest_workflow(had_data, outcome='y', unit='unit', - time='period', dose='dose', - aggregate='event_study') + time='period', dose='dose') est = HeterogeneousAdoptionDiD() results = est.fit(had_data, outcome='y', unit='unit', - time='period', dose='dose', - aggregate='event_study') + time='period', dose='dose') # Event-study results: per-horizon WAS at each event time for e, att in zip(results.event_times, results.att): diff --git a/docs/dev-status.md b/docs/dev-status.md index 5ea31b45..b94f7ce7 100644 --- a/docs/dev-status.md +++ b/docs/dev-status.md @@ -15,8 +15,8 @@ Target: ideally < 1000 lines per module; modules ≥3000 lines are candidates fo | `chaisemartin_dhaultfoeuille.py` | 8812 | Consider splitting (per-path / placebos / survey IF / aggregation) | | `linalg.py` | 5424 | Consider splitting (vcov surfaces) only if cohesion preserved — unified backend; vcov / solver paths tightly coupled | | `staggered.py` | 4992 | Consider splitting — grew through survey + aggregation features | -| `had.py` | 4748 | Consider splitting (continuous / mass-point / event-study / survey paths) | -| `had_pretests.py` | 4664 | Consider splitting (Stute / Yatchew / QUG / joint pretests) | +| `had.py` | 4906 | Consider splitting (continuous / mass-point / event-study / survey paths) | +| `had_pretests.py` | 4769 | Consider splitting (Stute / Yatchew / QUG / joint pretests) | | `diagnostic_report.py` | 4135 | Consider splitting (per-method renderers + provenance) | | `spillover.py` | 3655 | Consider splitting | | `two_stage.py` | 2430 | Monitor — exited the splitting band when the M-022 aggregate() migration extracted the Stage-2/GMM engine into `two_stage_aggregation.py` | diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index d1bd3451..080e9e92 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -1061,7 +1061,7 @@ Dynamic placebos `DID^{pl}_l` look backward from each group's reference period, - **Note (Phase 2 cost-benefit delta SE):** When `L_max >= 2`, `overall_att` holds the cost-benefit `delta`. Its SE is computed via the delta method from per-horizon SEs: `SE(delta) = sqrt(sum w_l^2 * SE(DID_l)^2)`, treating horizons as independent (conservative under Assumption 8). When bootstrap is enabled, per-horizon bootstrap SEs flow through the delta-method formula, so `overall_se` reflects bootstrap-derived per-horizon uncertainty but the delta aggregation itself uses normal-theory (not bootstrap percentile). This is an intentional exception to the general bootstrap-inference-surface contract: `overall_p_value` and `overall_conf_int` for `delta` use `safe_inference(delta, delta_se)`, not percentile bootstrap, because the delta is a derived aggregate rather than a directly bootstrapped estimand. -- **Note (post-fit `aggregate()` is a view - row M-026):** `results.aggregate('event_study')` and `results.aggregate('simple')` are pure VIEWS over stored fields - nothing is recomputed. `'event_study'` returns the unified `EventStudyResults` container (Phase-1 `L_max=None` fits: the 2-row l=1 view; `L_max >= 1`: the multi-horizon `l1_first_switch` surface); `'simple'` a one-row `AggregationResult` relaying `overall_att/se/t/p/CI` bit-exactly with the estimand-aware `target` label (DID_M / DID_1 / delta, or the trends-linear first-difference label whose overall row is all-NaN by design). Because it is a view, BOOTSTRAP FITS ARE PERMITTED - deviating from CallawaySantAnna's fail-closed `aggregate()`, whose kit-based recompute could silently substitute analytical inference; here each row relays exactly the inference the fit stored, including the delta's analytical-with-df numbers under bootstrap per the `Note (Phase 2 cost-benefit delta SE)` above (the view's `df` column resolves from the actual inference path, not the bootstrap-cleared `event_study_df` channel). The dCDH event-study CONTAINER is deliberately rejected by `compute_honest_did`/`compute_pretrends_power` - its l1 placebo semantics need HonestDiD's native dCDH branch (mandatory reinterpretation warning + horizon trimming); pass the results object itself. +- **Note (post-fit `aggregate()` is a view - row M-026):** `results.aggregate('event_study')` and `results.aggregate('simple')` are pure VIEWS over stored fields - nothing is recomputed. `'event_study'` returns the unified `EventStudyResults` container (Phase-1 `L_max=None` fits: the 2-row l=1 view; `L_max >= 1`: the multi-horizon `l1_first_switch` surface); `'simple'` a one-row `AggregationResult` relaying `overall_att/se/t/p/CI` bit-exactly with the estimand-aware `target` label (DID_M / DID_1 / delta, or the trends-linear first-difference label whose overall row is all-NaN by design). Because it is a view, BOOTSTRAP FITS ARE PERMITTED - the library-wide per-level relay rule (since M-027 the recompute adopters' 'simple' relays are permitted on bootstrap fits too, while their kit-based recompute levels stay closed - a recompute could silently substitute analytical inference); here each row relays exactly the inference the fit stored, including the delta's analytical-with-df numbers under bootstrap per the `Note (Phase 2 cost-benefit delta SE)` above (the view's `df` column resolves from the actual inference path, not the bootstrap-cleared `event_study_df` channel). The dCDH event-study CONTAINER is deliberately rejected by `compute_honest_did`/`compute_pretrends_power` - its l1 placebo semantics need HonestDiD's native dCDH branch (mandatory reinterpretation warning + horizon trimming); pass the results object itself. - **Note (dynamic placebo SE - library extension):** Dynamic placebos `DID^{pl}_l` (negative horizons in `placebo_event_study`) now have analytical SE and bootstrap SE when `L_max >= 1`. The placebo IF uses the same cohort-recentered structure as positive horizons, applied to backward outcome differences `Y_{g, F_g-1-l} - Y_{g, F_g-1}` with the dual-eligibility control pool (forward + backward observation required). The paper's Theorem 1 variance result is stated for `DID_l`, not `DID^{pl}_l` - this extension applies the same IF/variance structure to the placebo estimand as a library enhancement. The single-period placebo `DID_M^pl` (`L_max=None`) retains NaN SE because the per-period aggregation path has no IF derivation. @@ -1315,7 +1315,7 @@ labels.* 6. **Note (discrete-treatment saturated regression — library extension beyond `contdid` v0.1.0):** `treatment_type="discrete"` estimates the dose-response by a **saturated regression** (CGBS 2024 Eq. 4.1) — one indicator per distinct dose level, so `beta_j = mean_{D=d_j}(ΔY − control) = ATT(d_j)` (a per-level 2×2 DiD) — instead of the B-spline sieve. `ACRT(d_j)` is the paper's **backward difference** on the grid `{d_0 = 0, d_1, …, d_J}` (Eq. 4.1 makes `d_0 = 0` the omitted category with `ATT(0) = 0`): `ACRT(d_j) = [ATT(d_j) − ATT(d_{j-1})]/(d_j − d_{j-1})` for `j ≥ 2`, and at the lowest positive level it references the zero-dose baseline, `ACRT(d_1) = [ATT(d_1) − 0]/(d_1 − 0) = ATT(d_1)/d_1`. So a single positive dose (`J = 1`, e.g. binary `D ∈ {0,1}`) yields `ACRT(d_1) = ATT(d_1)/d_1`, and for `d_1 = 1` the documented binary identity `ACRT = ATT` holds exactly. This is a **library extension**: `contdid` v0.1.0 accepts `treatment_type` in its signature but **does not implement the discrete path** (documented "Discrete treatment not yet implemented"), so there is **no external R anchor**. It is instead an *exact* basis swap of the B-spline design/evaluation/derivative trio for an indicator/identity/finite-difference trio; every downstream quantity is linear in `beta`, so the analytical-SE / multiplier-bootstrap / covariate (reg,dr) / survey machinery is reused unchanged and reduces *analytically* to the per-level 2×2 DiD (`bread @ psi_bar = ones(J)`; the common control mean cancels in the `j ≥ 2` adjacent differences whose `L`-rows sum to 0). **reg vs dr:** the constant DR augmentation `η̄_cont` cancels in the `j ≥ 2` differences, so `ACRT(d_j)` point AND SE are identical for `reg`/`dr` there; but `ACRT(d_1) = ATT(d_1)/d_1` references the fixed baseline `ATT(0) = 0` (not shifted by `η̄_cont`), so `reg` and `dr` genuinely **differ at `ACRT(d_1)` by `η̄_cont/d_1`** (and correspondingly in `ACRT^glob` via the `d_1` mass) — the dr influence function carries the augmentation variance at `d_1` (validated: analytical `ACRT(d_1)` SE matches the multiplier bootstrap). Validation (R-free, in CI): exact hand-calc of `ATT(d_j)`/`ACRT`/`overall_att` and the analytical SE against a direct per-level 2×2 reconstruction (`~1e-12`/`~1e-10`), DGP recovery, and MC coverage for analytical + bootstrap (`tests/test_methodology_continuous_did.py::TestDiscreteSaturated`, `tests/test_continuous_did.py::TestDiscreteSaturatedAPI`). **Fail-closed policies (no-silent-failures):** (i) multi-cohort fits with **heterogeneous dose support** across cohorts raise `NotImplementedError` — an absent global level yields a dropped zero column (`att_d[level]=0`) that the plain-sum dose aggregation would bias toward zero (support-aware aggregation is deferred; single-cohort, 2-period, and shared-support multi-cohort are supported); (ii) a requested `dvals` value that is not an observed dose level raises `ValueError` (a saturated model cannot be evaluated off-support); (iii) an over-parameterized fit (`< 2` treated units per level, or `J > n_treated/2`) warns (degenerate per-level SE); (iv) with `survey_design=`, any dose level with **zero effective treated mass in a `(g,t)` cell** raises `ValueError` — a per-cell check (not just the global positive-weight check), so a level that survey/subpopulation weights zero out for one cohort while another cohort keeps it cannot silently drop to a zero-coefficient saturated column. Cross-references `docs/methodology/continuous-did.md` § 5.1. 7. **Note (lowest-dose-as-control, Remark 3.1 — library extension beyond `contdid` v0.1.0):** `control_group="lowest_dose"` implements CGBS 2024 Remark 3.1 for settings with no untreated group (`P(D=0) = 0`): the lowest-dose group `d_L` becomes the comparison and the estimand is `ATT(d) − ATT(d_L)` (SPT), with `ATT(d_L) = 0` the omitted reference. Mechanically it is a **control-group swap** — the D=0 control pool is replaced by the `d_L` group; the entire linear influence-function / bootstrap / event-study / survey machinery is control-group-generic and reused unchanged (`ee_control` already carries the reference-group variance, so **no new SE plumbing**). On the discrete saturated basis the backward-difference operator's reference shifts from `0` to `d_L` (`ACRT(d_1) = ATT(d_1)/(d_1 − d_L)`); on the continuous B-spline path the reference shifts only `μ_0` (the level), leaving `ACRT = spline'` unchanged. `contdid` v0.1.0 does **not** implement Remark 3.1, so there is **no external R anchor**; validation (R-free, in CI): an **exact `d_L → 0` equivalence** anchor (relabelling a `never_treated` panel's D=0 group as a tiny common dose `d_L = ε` reproduces the `never_treated` ATT and SE exactly, for any ε), a discrete hand-calc of `ATT(d)−ATT(d_L)`/`ACRT`/`overall_att`/`overall_acrt` and the per-level 2×2 SE (`~1e-10`), continuous mass-point DGP recovery, analytical-vs-bootstrap SE agreement, a pre-period placebo, and MC coverage (`tests/test_methodology_continuous_did.py::TestLowestDose`, `tests/test_continuous_did.py::TestLowestDoseAPI`). The continuous path requires a genuine **mass point** at the minimum dose (`>= 2` units at `d_L`, i.e. `P(D=d_L) > 0`) — the Remark 3.1 identification condition; a singleton minimum fails closed. **Fail-closed policies (no-silent-failures):** (i) never-treated units present with `lowest_dose` → `ValueError` (they would be silently dropped); (ii) singleton `d_L` (no mass point) → `ValueError`; (iii) no treated dose above `d_L` → `ValueError`; (iv) user `dvals ≤ d_L` → `ValueError` (`d_L` is the omitted reference); (v) survey/subpopulation weighting that leaves the `d_L` group with `< 2` positive-weight units → `ValueError` (a single positive-weight reference unit gives `ee_control = 0`, i.e. zero control-side variance — the effective-`>= 2` analogue of the raw mass-point guard, applied after weighting); (vi) a boundary gap `d_1 − d_L` that is a tiny fraction of the dose range warns (huge boundary ACRT/SE). **Deferred (fail-closed `NotImplementedError` + TODO):** multi-cohort `lowest_dose` (needs a within-cohort reference + support-aware cross-cohort aggregation) and `covariates=` × `lowest_dose` (conditional-PT-relative-to-`d_L` estimand). Cross-references `docs/methodology/continuous-did.md` § 5.6. -8. **Note (post-fit aggregate() - rows M-025/M-122):** `fit(aggregate=)` is deprecated in 3.9 (removed in 4.0; the no-underscore `"eventstudy"` spelling dies with it) in favor of post-fit `results.aggregate(type)` on the unified vocabulary + `'dose'` as this estimator's documented extra level; the PRE-EXISTING fit-time value validation is retained (unknown strings still raise `ValueError` after the deprecation warning - unlike the EfficientDiD/Imputation shims, which never validated). (a) **MIXED view/recompute architecture** (unique among the aggregate-postfit adopters): the dose-response curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by `fit()` (`aggregate="dose"` was a fit-time no-op), so `aggregate('simple')` (2 rows, targets att/acrt - the dual-estimand case the `AggregationResult.target` column exists for; `n` = the DISJOINT treated+control unit total, `n_kind='units'`) and `aggregate('dose')` (2N target-discriminated rows; labels = the dose grid twice; no count/mass per row) are pure VIEWS over stored public fields, PERMITTED on bootstrap fits (the dCDH deviation from CallawaySantAnna's fail-closed rule, applied per level) - they relay stored inference verbatim, including the FINITE `safe_inference` t-stat fit stores beside the percentile p/CI on bootstrapped overall rows and the `DoseResponseCurve.to_dataframe`-exact NaN-t derivation on dose rows; only the df column is uniformly NaN under bootstrap. (b) **`aggregate('event_study')` recomputes** the binarized event study from a pruned per-cell IF payload retained on the fit-built kit: per-(g,t) treated/control positional indices, `delta_y_treated`, `ee_control`, masses and the covariate-path `if_att_glob` (O(n_treated+n_control) per cell), unit-level arrays, and - on survey fits - the PANEL-LEVEL `ResolvedSurveyDesign` ref (the recompute performs the unit collapse itself, keeping the moved body verbatim; on replicate designs the (n_obs x R) replicate matrix rides along - the documented memory cost). The K-dimensional spline machinery (bread, `ee_treated`, `Psi_eval`, `dPsi_*`) is NOT retained; no panel data columns and no raw unit identifiers are retained. Replicate-weight designs ARE supported post-fit (IF-based `compute_replicate_if_variance` - no refit replay). Bootstrap fits carry a SCALARS-ONLY kit and the event-study route fails closed (`NotImplementedError`; the deprecated fit-time `aggregate='eventstudy'` computes the bootstrap surface until 4.0, or re-fit with `n_bootstrap=0`) - a seeded post-fit bootstrap replay is the TODO.md row. (c) **Fit-faithful quirk:** when no post-treatment (g,t) cells exist, event-study rows keep NaN inference on BOTH routes (the fit-time surface never fills them). (d) **df provenance:** the stored `dose_response_att.df_survey` channel (the value every fit-time `safe_inference` received) drives the views' df column (finite-and-positive else NaN - the replicate-undefined 0 sentinel reports NaN in the column but feeds the t/p derivation raw); the post-fit event-study container exposes the scalar `df_survey` channel only (all-NaN per-row df - the M-092 completion hole, tracked in TODO.md). (e) **Rendering:** this is the FIRST heterogeneous-`target` `AggregationResult`; `summary()`/`to_dataframe()` gained the target column / first-appearance target-block ordering amendment (normative rule in `docs/v4-design.md` section 6; uniform-target producers byte-stable). (f) **Consumer admission:** `compute_honest_did`/`compute_pretrends_power` reject ContinuousDiD containers BY DESIGN - no joint event-study covariance exists (per-bin IF SEs only) and the binarized bins carry no reference-period normalization at all (see M-093). Warning stacklevels in the moved bodies remain tuned for the fit-time frame depth, so post-fit-route warnings attribute to a library frame (the shipped EfficientDiD convention). +8. **Note (post-fit aggregate() - rows M-025/M-122):** `fit(aggregate=)` is deprecated in 3.9 (removed in 4.0; the no-underscore `"eventstudy"` spelling dies with it) in favor of post-fit `results.aggregate(type)` on the unified vocabulary + `'dose'` as this estimator's documented extra level; the PRE-EXISTING fit-time value validation is retained (unknown strings still raise `ValueError` after the deprecation warning - unlike the EfficientDiD/Imputation shims, which never validated). (a) **MIXED view/recompute architecture** (unique among the aggregate-postfit adopters): the dose-response curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by `fit()` (`aggregate="dose"` was a fit-time no-op), so `aggregate('simple')` (2 rows, targets att/acrt - the dual-estimand case the `AggregationResult.target` column exists for; `n` = the DISJOINT treated+control unit total, `n_kind='units'`) and `aggregate('dose')` (2N target-discriminated rows; labels = the dose grid twice; no count/mass per row) are pure VIEWS over stored public fields, PERMITTED on bootstrap fits (the library-wide per-level relay rule, since M-027 converged CS/EDiD/Imputation/TwoStage onto it) - they relay stored inference verbatim, including the FINITE `safe_inference` t-stat fit stores beside the percentile p/CI on bootstrapped overall rows and the `DoseResponseCurve.to_dataframe`-exact NaN-t derivation on dose rows; only the df column is uniformly NaN under bootstrap. (b) **`aggregate('event_study')` recomputes** the binarized event study from a pruned per-cell IF payload retained on the fit-built kit: per-(g,t) treated/control positional indices, `delta_y_treated`, `ee_control`, masses and the covariate-path `if_att_glob` (O(n_treated+n_control) per cell), unit-level arrays, and - on survey fits - the PANEL-LEVEL `ResolvedSurveyDesign` ref (the recompute performs the unit collapse itself, keeping the moved body verbatim; on replicate designs the (n_obs x R) replicate matrix rides along - the documented memory cost). The K-dimensional spline machinery (bread, `ee_treated`, `Psi_eval`, `dPsi_*`) is NOT retained; no panel data columns and no raw unit identifiers are retained. Replicate-weight designs ARE supported post-fit (IF-based `compute_replicate_if_variance` - no refit replay). Bootstrap fits carry a SCALARS-ONLY kit and the event-study route fails closed (`NotImplementedError`; the deprecated fit-time `aggregate='eventstudy'` computes the bootstrap surface until 4.0, or re-fit with `n_bootstrap=0`) - a seeded post-fit bootstrap replay is the TODO.md row. (c) **Fit-faithful quirk:** when no post-treatment (g,t) cells exist, event-study rows keep NaN inference on BOTH routes (the fit-time surface never fills them). (d) **df provenance:** the stored `dose_response_att.df_survey` channel (the value every fit-time `safe_inference` received) drives the views' df column (finite-and-positive else NaN - the replicate-undefined 0 sentinel reports NaN in the column but feeds the t/p derivation raw); the post-fit event-study container exposes the scalar `df_survey` channel only (all-NaN per-row df - the M-092 completion hole, tracked in TODO.md). (e) **Rendering:** this is the FIRST heterogeneous-`target` `AggregationResult`; `summary()`/`to_dataframe()` gained the target column / first-appearance target-block ordering amendment (normative rule in `docs/v4-design.md` section 6; uniform-target producers byte-stable). (f) **Consumer admission:** `compute_honest_did`/`compute_pretrends_power` reject ContinuousDiD containers BY DESIGN - no joint event-study covariance exists (per-bin IF SEs only) and the binarized bins carry no reference-period normalization at all (see M-093). Warning stacklevels in the moved bodies remain tuned for the fit-time frame depth, so post-fit-route warnings attribute to a library frame (the shipped EfficientDiD convention). ### Implementation Checklist @@ -1524,7 +1524,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 `e == balance_e`, then retain all their horizons) - the SAME rule CallawaySantAnna uses, divergent only from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits fail closed for ALL levels: ES/group because per-horizon draws are not retained (exact-replay wiring is a TODO row), and 'simple' for uniform CS-parity conservatism BY DECISION - the stored overall row could relay (as dCDH's view-only aggregate permits), but EDiD keeps one uniform bootstrap rule so no level publishes analytical-provenance fields beside percentile inference; the same rule clears the fit-time group rows' `df_used` key under bootstrap. (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`, all-NaN per-row df - the scalar `df_survey` channel is the container's only df provenance; the per-row hole is the tracked M-092-completion TODO row). 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`. +- **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 `e == balance_e`, then retain all their horizons) - the SAME rule CallawaySantAnna uses, divergent only from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column, while the RECOMPUTE levels (ES/group) fail closed - per-horizon draws are not retained (exact-replay wiring is a TODO row). The prior uniform-conservatism BY-DECISION rule was superseded 2026-08-05 with the M-027 per-level convergence; its rationale - no level publishes analytical-provenance fields beside percentile inference - is honored by the relay's NaN df column. The fit-time bootstrap override still clears the group rows' `df_used` key. (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`, all-NaN per-row df - the scalar `df_survey` channel is the container's only df provenance; the per-row hole is the tracked M-092-completion TODO row). 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`. --- @@ -1869,7 +1869,7 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only. - [x] Supports balanced and unbalanced panels (iterative Gauss-Seidel demeaning for exact FE) - [x] Event study and group aggregation -- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `imputation_did` wrapper forwards the shared sentinel so plain wrapper calls never warn) in favor of post-fit `ImputationDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit (not an EIF-payload kit: ES/group aggregation is a target-specific Theorem-3 recompute - each `balance_e` re-masks which treated observations enter every horizon and re-solves the untreated projection - so no compact influence payload can replace the frame). (a) RETAINED BUFFERS (memory contract): the kit's bookkeeping holds REFERENCES to the SAME per-fit objects `_fit_data` already retains for `pretrend_test()` - the working panel copy (all user columns plus `_tau_hat`/`_rel_time`/`_never_treated`), the Omega masks, `unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`, the resolved survey design, and `survey_weights` - ZERO marginal memory, and pickles are unchanged via memoization (`_estimator_ref` already ships these objects); plus value SNAPSHOTS for isolation (a `treatment_groups` copy, config scalars, a `dataclasses.replace` copy of `survey_metadata`, `overall_att`, `n_treated_obs`) and TWO df-provenance scalars (`survey_df_seed`, what the analytical aggregators received; `survey_df_final`, what the stored overall inference received). Each `aggregate()` call runs on a fresh throwaway host with a call-local projection cache (the fit-local factorizations are unpicklable and never retained). (b) `balance_e` uses the BALANCED-WINDOW rule: a cohort is retained iff its observed relative-time set - checked against the FULL panel via `_build_cohort_rel_times()` - covers the contiguous window `[-balance_e, max_h]`; the SAME rule TwoStageDiD uses, divergent from CS/EfficientDiD's anchor-horizon rule. A window no cohort satisfies warns and yields the reference-marker-only dict (a legal near-empty container). (c) BOOTSTRAP fits fail closed for ALL levels including 'simple' (uniform CS/EDiD parity; the per-target psi machinery makes exact replay tractable - a TODO row). (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the surface carries no joint event-study covariance - per-horizon conservative SEs only (container `vcov=None`; the scalar `df_survey` channel is its only df provenance, the per-row hole being the tracked M-092-completion TODO row). (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (the treated/control UNIT sets overlap - a treated unit with pre-periods counts in both - so the CS/EDiD disjoint-units convention cannot apply; |Omega_1| is the population the ATT averages over) and `df = survey_df_final`; 'group' rows carry per-row `df_used` captured at each row's `safe_inference` (the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via `.get`); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replicate_override_aggregates` with a LEVEL-MATCHED stack: `compute_replicate_refit_variance` validates replicates JOINTLY (all-finite rows), so `aggregate(L)` reproduces `fit(aggregate=L)` exactly, a `fit(aggregate='all')` surface is NOT the equivalence target when a replicate NaNs on exactly one family's targets, and - the documented migration delta - moving a replicate fit from `fit(aggregate=)` to plain fit + post-fit `aggregate()` can change the public OVERALL row's se/CI/df on such degenerate designs (each surface self-consistent; pinned in the contract tests). `pretrends=True` + replicate: post-fit `aggregate('event_study')` raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); 'group'/'simple' still work. Recompute re-emits the fit-time warnings (LSMR, Prop-5, empty-window) with fit-tuned stacklevels - post-fit attribution lands on a library frame, an accepted verbatim-move trade-off. +- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `imputation_did` wrapper forwards the shared sentinel so plain wrapper calls never warn) in favor of post-fit `ImputationDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit (not an EIF-payload kit: ES/group aggregation is a target-specific Theorem-3 recompute - each `balance_e` re-masks which treated observations enter every horizon and re-solves the untreated projection - so no compact influence payload can replace the frame). (a) RETAINED BUFFERS (memory contract): the kit's bookkeeping holds REFERENCES to the SAME per-fit objects `_fit_data` already retains for `pretrend_test()` - the working panel copy (all user columns plus `_tau_hat`/`_rel_time`/`_never_treated`), the Omega masks, `unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`, the resolved survey design, and `survey_weights` - ZERO marginal memory, and pickles are unchanged via memoization (`_estimator_ref` already ships these objects); plus value SNAPSHOTS for isolation (a `treatment_groups` copy, config scalars, a `dataclasses.replace` copy of `survey_metadata`, `overall_att`, `n_treated_obs`) and TWO df-provenance scalars (`survey_df_seed`, what the analytical aggregators received; `survey_df_final`, what the stored overall inference received). Each `aggregate()` call runs on a fresh throwaway host with a call-local projection cache (the fit-local factorizations are unpicklable and never retained). (b) `balance_e` uses the BALANCED-WINDOW rule: a cohort is retained iff its observed relative-time set - checked against the FULL panel via `_build_cohort_rel_times()` - covers the contiguous window `[-balance_e, max_h]`; the SAME rule TwoStageDiD uses, divergent from CS/EfficientDiD's anchor-horizon rule. A window no cohort satisfies warns and yields the reference-marker-only dict (a legal near-empty container). (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (the per-target psi machinery makes exact replay tractable - a TODO row); the prior uniform fail-closed rule was superseded 2026-08-05 with the M-027 per-level convergence. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the surface carries no joint event-study covariance - per-horizon conservative SEs only (container `vcov=None`; the scalar `df_survey` channel is its only df provenance, the per-row hole being the tracked M-092-completion TODO row). (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (the treated/control UNIT sets overlap - a treated unit with pre-periods counts in both - so the CS/EDiD disjoint-units convention cannot apply; |Omega_1| is the population the ATT averages over) and `df = survey_df_final`; 'group' rows carry per-row `df_used` captured at each row's `safe_inference` (the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via `.get`); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replicate_override_aggregates` with a LEVEL-MATCHED stack: `compute_replicate_refit_variance` validates replicates JOINTLY (all-finite rows), so `aggregate(L)` reproduces `fit(aggregate=L)` exactly, a `fit(aggregate='all')` surface is NOT the equivalence target when a replicate NaNs on exactly one family's targets, and - the documented migration delta - moving a replicate fit from `fit(aggregate=)` to plain fit + post-fit `aggregate()` can change the public OVERALL row's se/CI/df on such degenerate designs (each surface self-consistent; pinned in the contract tests). `pretrends=True` + replicate: post-fit `aggregate('event_study')` raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); 'group'/'simple' still work. Recompute re-emits the fit-time warnings (LSMR, Prop-5, empty-window) with fit-tuned stacklevels - post-fit attribution lands on a library frame, an accepted verbatim-move trade-off. --- @@ -1958,7 +1958,7 @@ Our implementation uses multiplier bootstrap on the GMM influence function: clus - [x] Multiplier bootstrap on GMM influence function - [x] Event study and overall ATT aggregation -- **Note (post-fit aggregate() - rows M-022/M-119):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `two_stage_did` wrapper forwards the shared sentinel so plain wrapper calls never warn) in favor of post-fit `TwoStageDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich on a level-specific design, so no compact influence payload exists. (a) RETAINED BUFFERS (memory contract - the FIRST panel retention on TwoStageDiD results, a deliberate break from the CS/EDiD identifier-minimization guarantee, with a `store_kit` opt-out tracked in DEFERRED.md): a COLUMN-SUBSET COPY of the working frame - `unit`/`time`/`outcome`/`first_treat` + covariates + the cluster column (deduplicated: `cluster=` may legally name a core column) + `_never_treated`/`_rel_time`/`_y_tilde` - O(n_obs) on every results object and pickle; the Stage-1 FE model (`unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`), the Omega masks, the full-domain `keep_mask`, the Wave-E.3-GATED `score_pad_mask`/`cluster_ids_full` values fit actually passed (None unless the always-treated pad was active), `survey_weights`, and the resolved survey design - on replicate designs that adds the O(n_obs x R) replicate matrix; plus value snapshots (`treatment_groups` copy, `ref_period`, `overall_att`, `n_treated_obs`, a `dataclasses.replace` copy of `survey_metadata`) and TWO df scalars (`survey_df_stage2`, the recompute seed; `survey_df_final`, what the stored overall inference received). (b) `balance_e` uses the BALANCED-WINDOW rule (`[-balance_e, max_h]` coverage against the full panel - the ImputationDiD rule, divergent from CS/EfficientDiD's anchor-horizon rule); zero qualifying cohorts warns and yields the reference-row-only dict with `vcov=None`. (c) BOOTSTRAP fits fail closed for ALL levels including 'simple' (per-level GMM scores are function-locals; replay is a TODO row); a fit whose bootstrap FAILED (`bootstrap_results=None`, analytical inference retained) aggregates normally. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (both terminal TypeErrors state it): analytical surfaces DO carry the real joint Gardner-GMM covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS - the reference horizon is dropped from the no-intercept Stage-2 design and 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 (either re-estimating Stage 2 with the reference horizon in the design or deriving the residual-to-reference mapping) - the DEFERRED.md paper-gated row. (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (overlapping unit sets - the StackedDiD carve-out class) and `df = survey_df_final` (on replicate fits that value came from the `[overall]`-only joint stack - snapshotted, never re-derived); 'group' relays a SCALAR df broadcast (deliberate divergence from ImputationDiD's per-row `df_used`: `_stage2_group` passes one immutable `survey_df` to every row's `safe_inference`, so the scalar is provenance-exact by construction and the moved method stays verbatim); 'event_study' reproduces the M-092 container contract exactly - analytical fits thread the recomputed joint vcov + `vcov_index` + the finite-and->0 df scalar through the carrier, replicate fits thread `vcov=None`/`index=None` with the REPLAYED level-matched df, and the carrier's metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replay_replicate_inference` with a LEVEL-MATCHED stack (the ImputationDiD semantics: `aggregate(L)` reproduces `fit(aggregate=L)`; `fit(aggregate='all')` is not the equivalence target on degenerate designs; the OVERALL-row migration delta on such designs is documented and pinned). Recompute re-emits fit-time warnings with fit-tuned stacklevels - an accepted verbatim-move trade-off. +- **Note (post-fit aggregate() - rows M-022/M-119):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `two_stage_did` wrapper forwards the shared sentinel so plain wrapper calls never warn) in favor of post-fit `TwoStageDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich on a level-specific design, so no compact influence payload exists. (a) RETAINED BUFFERS (memory contract - the FIRST panel retention on TwoStageDiD results, a deliberate break from the CS/EDiD identifier-minimization guarantee, with a `store_kit` opt-out tracked in DEFERRED.md): a COLUMN-SUBSET COPY of the working frame - `unit`/`time`/`outcome`/`first_treat` + covariates + the cluster column (deduplicated: `cluster=` may legally name a core column) + `_never_treated`/`_rel_time`/`_y_tilde` - O(n_obs) on every results object and pickle; the Stage-1 FE model (`unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`), the Omega masks, the full-domain `keep_mask`, the Wave-E.3-GATED `score_pad_mask`/`cluster_ids_full` values fit actually passed (None unless the always-treated pad was active), `survey_weights`, and the resolved survey design - on replicate designs that adds the O(n_obs x R) replicate matrix; plus value snapshots (`treatment_groups` copy, `ref_period`, `overall_att`, `n_treated_obs`, a `dataclasses.replace` copy of `survey_metadata`) and TWO df scalars (`survey_df_stage2`, the recompute seed; `survey_df_final`, what the stored overall inference received). (b) `balance_e` uses the BALANCED-WINDOW rule (`[-balance_e, max_h]` coverage against the full panel - the ImputationDiD rule, divergent from CS/EfficientDiD's anchor-horizon rule); zero qualifying cohorts warns and yields the reference-row-only dict with `vcov=None`. (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform rule superseded 2026-08-05 with the M-027 per-level convergence; a fit whose bootstrap FAILED (`bootstrap_results=None`, analytical inference retained) aggregates normally. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (both terminal TypeErrors state it): analytical surfaces DO carry the real joint Gardner-GMM covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS - the reference horizon is dropped from the no-intercept Stage-2 design and 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 (either re-estimating Stage 2 with the reference horizon in the design or deriving the residual-to-reference mapping) - the DEFERRED.md paper-gated row. (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (overlapping unit sets - the StackedDiD carve-out class) and `df = survey_df_final` (on replicate fits that value came from the `[overall]`-only joint stack - snapshotted, never re-derived); 'group' relays a SCALAR df broadcast (deliberate divergence from ImputationDiD's per-row `df_used`: `_stage2_group` passes one immutable `survey_df` to every row's `safe_inference`, so the scalar is provenance-exact by construction and the moved method stays verbatim); 'event_study' reproduces the M-092 container contract exactly - analytical fits thread the recomputed joint vcov + `vcov_index` + the finite-and->0 df scalar through the carrier, replicate fits thread `vcov=None`/`index=None` with the REPLAYED level-matched df, and the carrier's metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replay_replicate_inference` with a LEVEL-MATCHED stack (the ImputationDiD semantics: `aggregate(L)` reproduces `fit(aggregate=L)`; `fit(aggregate='all')` is not the equivalence target on degenerate designs; the OVERALL-row migration delta on such designs is documented and pinned). Recompute re-emits fit-time warnings with fit-tuned stacklevels - an accepted verbatim-move trade-off. --- @@ -3628,7 +3628,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 when `aggregate="event_study"` AND `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 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. - **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. @@ -3661,7 +3661,7 @@ Under `survey_design=SurveyDesign(weights, strata, psu, fpc)`, the variance comp - **Extensive-margin effects**: ruled out by Assumption 3. If a jump `Y_2(0) ≠ Y_2(0+)` is suspected, the target parameter and estimator are not appropriate. - **Partial identification of WAS_{d̲}**: only identified up to a positive constant offset `≤ ε` by the bound in Equation 22 (Jensen inequality argument in Appendix C.3). - **Density at boundary**: Assumption 4 requires `f_{D_2}(0) > 0`. This is a non-trivial assumption since 0 is on the boundary of `Supp(D_2)`. -- **Variation in treatment timing**: Appendix B.2 - "in designs with variation in treatment timing, there must be an untreated group, at least till the period where the last cohort gets treated." In Phase 2b (`aggregate="event_study"`) the implementation auto-filters to the last-treatment cohort plus never-treated units with a `UserWarning` when `first_treat` is supplied (see Phase 2b last-cohort filter note below); when `first_treat` is omitted the estimator detects multiple first-positive-dose cohorts from the dose path and raises a front-door `ValueError` directing users to pass `first_treat` or use `ChaisemartinDHaultfoeuille`. +- **Variation in treatment timing**: Appendix B.2 - "in designs with variation in treatment timing, there must be an untreated group, at least till the period where the last cohort gets treated." In the event-study mode (Phase 2b) the implementation auto-filters to the last-treatment cohort plus never-treated units with a `UserWarning` when `first_treat` is supplied (see Phase 2b last-cohort filter note below); when `first_treat` is omitted the estimator detects multiple first-positive-dose cohorts from the dose path and raises a front-door `ValueError` directing users to pass `first_treat` or use `ChaisemartinDHaultfoeuille`. - **Mechanical zero at reference period under linear trends (Footnote 13, main text p. 31)**: with industry/unit-specific linear trends, the pre-trends estimator is mechanically zero in the second-to-last pre-period (the slope anchor year). Practical consequence: that year is not an informative placebo check. *Algorithm (Design 1' nonparametric - summarized from Section 3.1.3-3.1.4 and Equations 7-8):* @@ -3699,10 +3699,10 @@ Tuning-parameter-free test of `H_0: d̲ = 0` versus `H_1: d̲ > 0`. Shipped in ` - `T_hr = sqrt(sum(w)) * (sigma2_lin - sigma2_diff) / sigma2_W` (effective-sample-size convention; reduces to `sqrt(G)` at `w=1`). Strictly positive weights required (the adjacent-difference variance is undefined under contiguous-zero blocks). PSU clustering is NOT propagated through the variance-ratio statistic (would require a survey-aware variance-of-variance estimator, out of scope). Pair-weight convention follows Krieger-Pfeffermann (1997, §3) for design-consistent inference on smooth functionals. - **Workflow** (`did_had_pretest_workflow`) under `survey_design=`: skips the QUG step with a `UserWarning` (per Phase 4.5 C0 deferral), sets `qug=None` on the report, and dispatches the linearity family with the survey-aware mechanism. Verdict carries a `"linearity-conditional verdict; QUG-under-survey deferred per Phase 4.5 C0"` suffix. `all_pass` drops the QUG-conclusiveness condition; the linearity-conditional rule splits by aggregate: - - `aggregate="overall"`: `True` iff at least one of `stute`/`yatchew` is conclusive AND no conclusive test rejects (paper Section 4 step-3 "Stute OR Yatchew" wording carries through). - - `aggregate="event_study"`: `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). + - the overall battery (two-period panels): `True` iff at least one of `stute`/`yatchew` is conclusive AND no conclusive test rejects (paper Section 4 step-3 "Stute OR Yatchew" wording carries through). + - the event-study battery (multi-period panels): `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). - **Replicate-weight survey designs (BRR/Fay/JK1/JKn/SDR) deferred** to a parallel follow-up. Each helper raises `NotImplementedError` on `survey.replicate_weights is not None` (defense in depth: workflow + every direct-helper entry rejects, mirroring the reciprocal-guard discipline from PR #346). The per-replicate weight-ratio rescaling for the OLS-on-residuals refit step is not covered by the multiplier-bootstrap composition above. - - **`lonely_psu='adjust'` with singleton strata is rejected** with `NotImplementedError` on the Stute family (mirrors HAD sup-t bootstrap at `had.py:2081-2118`). The bootstrap multiplier helper pools singleton strata into a pseudo-stratum with nonzero multipliers, but the analytical variance target requires a pseudo-stratum centering transform that has not been derived for the Stute CvM. Use `lonely_psu='remove'` (drops singleton contributions) or `'certainty'` (zero-variance singletons); both produce all-zero singleton multipliers that match a well-defined analytical target. Variance-unidentified designs (`df_survey <= 0` after the adjust+singleton case is handled) return `NaN` with a `UserWarning` (single-PSU unstratified or one-PSU-per-stratum under remove/certainty). + - **`lonely_psu='adjust'` with singleton strata is rejected** with `NotImplementedError` on the Stute family (mirrors HAD sup-t bootstrap at `had.py:2194-2231`). The bootstrap multiplier helper pools singleton strata into a pseudo-stratum with nonzero multipliers, but the analytical variance target requires a pseudo-stratum centering transform that has not been derived for the Stute CvM. Use `lonely_psu='remove'` (drops singleton contributions) or `'certainty'` (zero-variance singletons); both produce all-zero singleton multipliers that match a well-defined analytical target. Variance-unidentified designs (`df_survey <= 0` after the adjust+singleton case is handled) return `NaN` with a `UserWarning` (single-PSU unstratified or one-PSU-per-stratum under remove/certainty). - **Stratified designs (`SurveyDesign(strata=...)`) are supported** via the standard stratified clustered wild bootstrap correction on the PSU multipliers (Cameron-Gelbach-Miller 2008; Davidson-Flachaire 2008; Djogbenou-MacKinnon-Nielsen 2019; Kreiss-Lahiri 2012; Wu 1986; Liu 1988). See the dedicated **"Note: Stute stratified survey-bootstrap calibration"** below for the algorithm. Remaining deferrals: `lonely_psu='adjust'` + singleton strata (same pseudo-stratum centering gap as the HAD sup-t deviation documented above; requires a separate analytical-target derivation) and replicate-weight designs (BRR/Fay/JK1/JKn/SDR; separate Rao-Wu/JKn bootstrap composition). - **Note (Stute stratified survey-bootstrap calibration):** The Stute survey-bootstrap is a wild residual bootstrap (Hlávka-Hušková 2020) with cluster-level multipliers (Cameron, Gelbach & Miller 2008). The per-replicate loop is `eta_obs = psu_mults[b, psu_col_idx]; dy_b = fitted + eps * eta_obs; refit weighted OLS; recompute weighted CvM` (the per-replicate loops in `stute_test` and `stute_joint_pretest`). Under stratified PSU sampling, the multipliers `psu_mults[b, :]` exit `generate_survey_multiplier_weights_batch` as within-stratum-independent draws with the `(1 - f_h)` FPC factor already baked in (`bootstrap_utils.py:579-651`). To make the bootstrap CvM variance match the analytical Binder-TSL stratified target `V_S = sum_h (1 - f_h) * (n_h / (n_h - 1)) * sum_{j in h} (psi_hj - psi_h_bar)²`, two additional corrections are applied to the multipliers BEFORE the per-obs broadcast. **Citations below are ingredients, not direct papers on this exact composition** — the specific recipe (within-stratum demean + Bessel rescale on PSU multipliers applied before broadcast in a wild-residual refit-in-loop bootstrap for the Stute CvM functional) is a library synthesis; no single paper covers all of it. @@ -3717,7 +3717,7 @@ Tuning-parameter-free test of `H_0: d̲ = 0` versus `H_1: d̲ > 0`. Shipped in ` Consistency of the resulting bootstrap CvM distribution under stratified PSU sampling follows from Djogbenou, MacKinnon & Nielsen (2019) Theorem 2 (empirical-process consistency of the cluster wild bootstrap), with the Krieger-Pfeffermann (1997) survey-weighted multiplier-bootstrap extension routed through the multiplier draws rather than the influence tensor. For the multi-horizon joint Stute (`stute_joint_pretest`), the same `psu_mults[b, :]` row is shared across horizons within each replicate, preserving cross-horizon empirical-process dependence (Hlávka-Hušková 2020 §3 condition) and PSU clustering. The combined correction is the standard non-parametric requirement and does not depend on the CvM functional shape — it works for any nonlinear smooth-functional bootstrap consumer of `eta_obs = psu_mults[b, psu_col_idx]`. - **Non-strata calibration improvement.** When `strata=None`, the correction is applied uniformly with a single implicit stratum (`n_h = n_psu`): demean across all PSUs, multiply by `sqrt(n_psu / (n_psu - 1))`. This mirrors the HAD sup-t convention at `had.py:2199-2204` and brings Stute non-strata into line with the sibling event-study path. The pre-PR Phase 4.5 C non-strata path applied no centering or rescaling — multipliers were raw iid draws. The bootstrap CvM p-values on non-strata designs (pweight-only, PSU-only, FPC-only) **shift by approximately `sqrt(n_psu / (n_psu - 1)) - 1`** relative to the pre-PR path (≈ 1.7% for `n_psu = 60`, decreasing to ≈ 0.5% for `n_psu = 100`). This is a calibration improvement, NOT a regression: the pre-PR path was under-corrected by exactly this factor. Two complementary regressions cover any revert of the helper or its wiring: (1) the helper bit-parity regression at `tests/test_bootstrap_utils.py::TestApplyStratumCentering::test_bit_parity_vs_pre_refactor_inline_block` (locked at `atol=1e-14`) catches any change to the helper's axis-0 algebra; (2) a wired-in regression at `tests/test_had_pretests.py::TestStuteStratifiedSurveyBootstrap::test_stute_call_sites_invoke_apply_stratum_centering` monkey-patches the helper and asserts both Stute call sites (`stute_test` and `stute_joint_pretest`) invoke it with `psu_axis=1`, which catches the disconnection case the helper bit-parity test does not. End-to-end Stute non-strata fit is exercised as a finite + range smoke (`tests/test_had_pretests.py::TestStuteStratifiedSurveyBootstrap::test_calibration_shift_non_strata_end_to_end_smoke`); a heavier worktree-based pre/post baseline comparison was considered and intentionally skipped as redundant with the helper-level bit-parity lock and the call-site wiring regression. + **Non-strata calibration improvement.** When `strata=None`, the correction is applied uniformly with a single implicit stratum (`n_h = n_psu`): demean across all PSUs, multiply by `sqrt(n_psu / (n_psu - 1))`. This mirrors the HAD sup-t convention at `had.py:2312-2317` and brings Stute non-strata into line with the sibling event-study path. The pre-PR Phase 4.5 C non-strata path applied no centering or rescaling — multipliers were raw iid draws. The bootstrap CvM p-values on non-strata designs (pweight-only, PSU-only, FPC-only) **shift by approximately `sqrt(n_psu / (n_psu - 1)) - 1`** relative to the pre-PR path (≈ 1.7% for `n_psu = 60`, decreasing to ≈ 0.5% for `n_psu = 100`). This is a calibration improvement, NOT a regression: the pre-PR path was under-corrected by exactly this factor. Two complementary regressions cover any revert of the helper or its wiring: (1) the helper bit-parity regression at `tests/test_bootstrap_utils.py::TestApplyStratumCentering::test_bit_parity_vs_pre_refactor_inline_block` (locked at `atol=1e-14`) catches any change to the helper's axis-0 algebra; (2) a wired-in regression at `tests/test_had_pretests.py::TestStuteStratifiedSurveyBootstrap::test_stute_call_sites_invoke_apply_stratum_centering` monkey-patches the helper and asserts both Stute call sites (`stute_test` and `stute_joint_pretest`) invoke it with `psu_axis=1`, which catches the disconnection case the helper bit-parity test does not. End-to-end Stute non-strata fit is exercised as a finite + range smoke (`tests/test_had_pretests.py::TestStuteStratifiedSurveyBootstrap::test_calibration_shift_non_strata_end_to_end_smoke`); a heavier worktree-based pre/post baseline comparison was considered and intentionally skipped as redundant with the helper-level bit-parity lock and the call-site wiring regression. **Validated via:** MC oracle consistency under a stratified null DGP (200 draws, 4 strata × 6 PSUs/stratum, weights+strata+PSU design — no FPC at the panel level; the helper's FPC bake-in is covered separately by `tests/test_bootstrap_utils.py::TestApplyStratumCentering::test_fpc_baked_in_helper_is_fpc_agnostic`); empirical Type I error at α=0.05 in `[0.0, 0.10]` (3σ band, seed-set). MC power under a stratified known-alternative DGP (same shape, quadratic `E[ΔY|D]`); rejection rate > 0.50 at α=0.05. @@ -3790,10 +3790,11 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in *Notes #1-#2 lock implementation choices (paper-permitted choices the library codified); Notes #3-#4 document validation-harness work waived in this PR with documented rationale; #5 is a Library extension where the library departs from the paper's prescription toward stricter safety.* - **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 when `aggregate="event_study"` AND `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:** 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). 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:1511` 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`. +- **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:** `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`. @@ -3846,7 +3847,7 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - [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 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] Warnings for staggered treatment timing (redirect to `ChaisemartinDHaultfoeuille`). **Closed 2026-05-20:** fail-closed `ValueError` at `diff_diff/had.py:1511` (see Deviations § "Library extension: Staggered-timing fail-closed" for the rationale on raising vs warning). +- [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 533655e5..3be109ed 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -184,9 +184,9 @@ A few branches emit a fixed tag regardless of fit-time config — notably `CallawaySantAnna`, `ImputationDiD`, `TwoStageDiD`, and `WooldridgeDiD`. For these estimators the `overall_att` (or `att` / `avg_att`) scalar is ALWAYS the simple weighted -aggregation; the fit-time `aggregate` kwarg populates additional -horizon / group tables on the result object but does not change -the headline scalar. Disambiguating those tables in prose is +aggregation; post-fit `results.aggregate()` (the successor to the +deprecated fit-time `aggregate` kwarg, rows M-020..M-027) returns +the horizon / group tables without changing the headline scalar. Disambiguating those tables in prose is tracked under BR/DR gap #9 (per-cohort narrative rendering). `ContinuousDiDResults` emits a single `"dose_overall"` tag with a diff --git a/docs/methodology/papers/dechaisemartin-2026-review.md b/docs/methodology/papers/dechaisemartin-2026-review.md index 1a14dc35..d9979dfc 100644 --- a/docs/methodology/papers/dechaisemartin-2026-review.md +++ b/docs/methodology/papers/dechaisemartin-2026-review.md @@ -187,7 +187,7 @@ Alternative to Stute when `G` is large or heteroskedasticity is suspected. - [x] Stute Cramér-von Mises test with Mammen wild bootstrap. **Phase 3 implementation (2026-04):** `stute_test()` in `diff_diff/had_pretests.py`. Literal per-iteration OLS refit per paper Appendix D Algorithm. `n_bootstrap=999` default, `n_bootstrap >= 99` validated. - [x] Yatchew heteroskedasticity-robust linearity test. **Phase 3 implementation (2026-04):** `yatchew_hr_test()` in `diff_diff/had_pretests.py`. Test statistic `T_hr = sqrt(G)·(σ²_lin - σ²_diff)/σ²_W` from paper Equation 29. `σ²_diff` normalizes by `2G` (paper-literal), NOT `2(G-1)` (finite-sample equivalent but tests pin the paper-literal form). Standard-normal critical value, one-sided. - [x] Composite workflow `did_had_pretest_workflow()` (paper Section 4.2-4.3). **Phase 3 implementation (2026-04):** `aggregate="overall"` (default, two-period) runs QUG + Stute + Yatchew on a two-period panel; step 2 is NOT run on this path because a two-period panel has no pre-period placebo horizon. **Phase 3 follow-up (2026-04):** `aggregate="event_study"` (multi-period) runs QUG at F + joint pre-trends Stute + joint homogeneity-linearity Stute; closes the paper step-2 gap. -- [x] Warnings for staggered treatment timing (direct users to existing `ChaisemartinDHaultfoeuille` in diff-diff). **Phase 4 closure (2026-05-20):** fail-closed `ValueError` at `diff_diff/had.py:1511` when multiple first-treat cohorts are detected without `first_treat`; the error message directs the user to either supply `first_treat` (which activates the last-cohort + never-treated auto-filter per Appendix B.2) or to use `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`) for full staggered support. The fail-closed choice (over `UserWarning`) is documented in REGISTRY Deviations § "Staggered-timing fail-closed" as a library extension toward stricter safety than the paper's "Warn" prescription. +- [x] Warnings for staggered treatment timing (direct users to existing `ChaisemartinDHaultfoeuille` in diff-diff). **Phase 4 closure (2026-05-20):** fail-closed `ValueError` at `diff_diff/had.py:1624` when multiple first-treat cohorts are detected without `first_treat`; the error message directs the user to either supply `first_treat` (which activates the last-cohort + never-treated auto-filter per Appendix B.2) or to use `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`) for full staggered support. The fail-closed choice (over `UserWarning`) is documented in REGISTRY Deviations § "Staggered-timing fail-closed" as a library extension toward stricter safety than the paper's "Warn" prescription. - [x] Warnings for extensive-margin effects / positive mass of untreated (not fatal; suggests running existing DiD). **Closed 2026-06-01:** `HeterogeneousAdoptionDiD.fit()` now emits a fit-time `UserWarning` on the **overall** path when `>= 10%` of units have an exactly-zero post-period dose — pointing the user to a standard DiD per the Section 4 recommendation. The 10% cutoff is a library convention (the paper prescribes "warn" with NO numeric threshold and explicitly retains small untreated shares, e.g. Garrett et al.'s 12/2954 ≈ 0.4% with close-to-nominal coverage), chosen ~25× above that kept example. Overall-path-only because the event-study path *requires* never-treated units per Appendix B.2 (so an untreated mass is expected there, not a misuse signal). This complements the pre-existing `qug_test()` zero-dose `UserWarning`, which surfaces the *presence* of extensive-margin / positive-mass-of-untreated units only when the user runs the pre-tests. Documented in REGISTRY § HeterogeneousAdoptionDiD ("Note (Extensive-margin / positive-untreated-mass fit-time warning)"); locked by `tests/test_methodology_had.py::TestHADDeviations::test_extensive_margin_warning_is_10pct_library_convention`. - [x] Documentation of non-testability of Assumptions 5 and 6. **Phase 4 closure (2026-05-20):** `HeterogeneousAdoptionDiD.fit()` emits a `UserWarning` at fit time when `resolved_design ∈ {continuous_near_d_lower, mass_point}` (Design 1 family) explicitly flagging that point identification of `WAS_{d_lower}` requires Assumption 6, sign identification requires Assumption 5, and NEITHER is testable via pre-trends (`diff_diff/had.py`, search for "---- Assumption 5/6 warning on Design 1 paths ----"). The `HeterogeneousAdoptionDiD` class docstring + `qug_test` / `stute_test` / `yatchew_hr_test` / `did_had_pretest_workflow` Notes sections cross-reference this and explicitly state that the available pre-tests verify ADJACENT identifying conditions: QUG tests the Theorem 4 / Design 1' support-infimum null `d_lower = 0` — adjacent evidence on the `d_lower = 0` clause of Assumption 4 only, NOT a test of full Assumption 4's boundary-density / conditional-mean smoothness / variance regularity statement; the raw `stute_test` / `yatchew_hr_test` helpers test Assumption 8 linearity (residuals from `dy ~ 1 + d`); `joint_pretrends_test` tests Assumption 7 mean-independence (intercept-only residuals via `null_form="mean_independence"`). None of these test Assumptions 5 or 6 directly. 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 caveat is surfaced separately by the Design 1 fit-time `UserWarning` and by T21 tutorial prose. - [x] Multi-period event-study extension (Appendix B.2). **Phase 2b implementation (2026-04):** `aggregate="event_study"` returns per-event-time WAS estimates using uniform `F-1` anchor. Staggered-timing contract (see L190 closure for full statement): when `first_treat` is supplied, the panel auto-filters to last-cohort + never-treated units with a `UserWarning` per Appendix B.2 prescription; when omitted on a multi-cohort panel, the estimator raises `ValueError` (fail-closed, see REGISTRY § "Library extension: Staggered-timing fail-closed"). Pointwise CIs per horizon (no joint cross-horizon covariance; matches paper's Pierce-Schott Figure 2). Pre-period placebos at `e <= -2`; the anchor `e = -1` is skipped since `ΔY = 0` there by construction. diff --git a/docs/methodology/papers/roth-2022-review.md b/docs/methodology/papers/roth-2022-review.md index c66f93df..b98ee70a 100644 --- a/docs/methodology/papers/roth-2022-review.md +++ b/docs/methodology/papers/roth-2022-review.md @@ -288,4 +288,4 @@ Quoting Roth's key empirical results (for cross-validation): - **Heteroskedastic Sigma**: Proposition 2 requires Assumption 1. The current `diff_diff/pretrends.py` implements NIS and Wald power/MDV (PR-B 2026-05-18; NIS is the default per `pretest_form='nis'`), but NOT the full conditional-moment path (Propositions 1 / 3 / 4 numerical evaluation). Once those are added, the library will be able to operate under arbitrary Sigma — but at that point the sign of the bias-amplification effect is NOT guaranteed without Assumption 1. The library should NOT print "pretest amplifies bias under monotone trends" unless Assumption 1 is approximately satisfied (or just always issue the conditional warning). - **Equation 4 publication-rules analysis**: not standardly implemented in PreTrendsPower-style tools. Roth notes it as part of the discussion (Section II.D) but does not provide a numerical workflow for users. Library should NOT attempt to implement Equation 4 unless requested. - **Connection to `compute_pretrends_power` library helper**: the paper review confirms that "minimum slope detectable at 80% power" is exactly Roth's gamma_{0.8}, and the library helper should compute and surface this. Need to verify the existing helper's calling convention against the paper's framework when auditing `diff_diff/pretrends.py`. -- **Compatibility with multi-cohort estimators**: Remark 1 lists Callaway-Sant'Anna, Sun-Abraham, etc. as compatible. The paper does not detail how to construct (beta_hat, Sigma_hat) from those estimators when the event-study output is multi-cohort (e.g., cohort × event-time matrix). Library should document the aggregation convention (per Sun-Abraham overall ATT or per Callaway-Sant'Anna `aggregate=event`). +- **Compatibility with multi-cohort estimators**: Remark 1 lists Callaway-Sant'Anna, Sun-Abraham, etc. as compatible. The paper does not detail how to construct (beta_hat, Sigma_hat) from those estimators when the event-study output is multi-cohort (e.g., cohort × event-time matrix). Library should document the aggregation convention (per Sun-Abraham overall ATT or per Callaway-Sant'Anna `results.aggregate('event_study')`). diff --git a/docs/r_comparison.rst b/docs/r_comparison.rst index c85a999d..6c46a56b 100644 --- a/docs/r_comparison.rst +++ b/docs/r_comparison.rst @@ -257,8 +257,7 @@ identification assumptions (the design path is auto-detected separately by est = HeterogeneousAdoptionDiD() results = est.fit(had_data, outcome='y', unit='unit', - time='period', dose='dose', - aggregate='event_study') + time='period', dose='dose') Key Differences --------------- diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 2f667b21..cc617e65 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -534,8 +534,7 @@ exceeding 2% routes to ``mass_point``; otherwise # Check the resolved estimand after fitting est = HeterogeneousAdoptionDiD() results = est.fit(had_data, outcome='y', unit='unit', - time='period', dose='dose', - aggregate='event_study') + time='period', dose='dose') print(f"Resolved: {results.target_parameter}") # If you intend Design 1' but `D_{g,F}.min()` exceeds the threshold, @@ -591,8 +590,7 @@ SE path is not used here). est = HeterogeneousAdoptionDiD() results = est.fit(had_data, outcome='y', unit='unit', - time='period', dose='dose', - aggregate='event_study') + time='period', dose='dose') # Inspect the resolved design print(f"Design: {results.design}") # 'mass_point' here @@ -634,8 +632,8 @@ full SE-regime contract. "Panel-only event-study restriction" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -**Problem:** ``HeterogeneousAdoptionDiD.fit(..., aggregate="event_study")`` -raises on a staggered panel. +**Problem:** ``HeterogeneousAdoptionDiD.fit()`` on a multi-period +(event-study mode) staggered panel raises. **Cause:** The Appendix B.2 event-study extension requires either a common-adoption panel (single first-treat period; ``first_treat`` is @@ -678,8 +676,7 @@ a ``UserWarning``). The fit raises only when the panel is staggered est = HeterogeneousAdoptionDiD() results = est.fit(data, outcome='y', unit='unit', time='period', dose='dose', - first_treat='first_treat', - aggregate='event_study') + first_treat='first_treat') # Equivalent: subset to the last-treatment cohort + never-treated # before fitting (skips the UserWarning). @@ -687,8 +684,7 @@ a ``UserWarning``). The fit raises only when the panel is staggered subset = data[(data['first_treat'] == last_cohort) | (data['first_treat'] == 0)] results = est.fit(subset, outcome='y', unit='unit', - time='period', dose='dose', - aggregate='event_study') + time='period', dose='dose') Imputation / Two-Stage DiD Issues ---------------------------------- diff --git a/docs/tutorials/15_efficient_did.ipynb b/docs/tutorials/15_efficient_did.ipynb index cdf75010..9733b150 100644 --- a/docs/tutorials/15_efficient_did.ipynb +++ b/docs/tutorials/15_efficient_did.ipynb @@ -570,7 +570,7 @@ "7. **Condition numbers** flag potentially unstable weight matrices\n", "8. **Anticipation** shifts the effective treatment boundary for pre-treatment effects\n", "9. **Covariates are supported** via the doubly robust path (sieve outcome regressions + propensity ratios) --- pass `covariates=[...]` to `fit()`\n", - "10. **Aggregate post-fit** (3.9): `results.aggregate('event_study'/'group'/'simple', balance_e=)` recomputes from retained EIFs --- no refit; fit-time `aggregate=`/`balance_e=` are deprecated, and bootstrapped fits keep the fit-time route\n", + "10. **Aggregate post-fit** (3.9): `results.aggregate('event_study'/'group'/'simple', balance_e=)` recomputes from retained EIFs --- no refit; fit-time `aggregate=`/`balance_e=` are deprecated, and on bootstrapped fits the recompute levels keep the fit-time route (`aggregate('simple')` relays the stored bootstrap inference post-fit)\n", "11. When in doubt, run both EDiD and CS --- if ATTs agree, report EDiD for tighter CIs\n", "\n", "**Parameter reference:**\n", diff --git a/docs/tutorials/20_had_brand_campaign.ipynb b/docs/tutorials/20_had_brand_campaign.ipynb index d2d70f8f..980c81d4 100644 --- a/docs/tutorials/20_had_brand_campaign.ipynb +++ b/docs/tutorials/20_had_brand_campaign.ipynb @@ -293,7 +293,6 @@ " time='week',\n", " unit='dma_id',\n", " first_treat='first_treat',\n", - " aggregate='event_study',\n", " )\n", "print(result_es.summary())" ] @@ -389,7 +388,7 @@ "This tutorial covered HAD's headline workflow: the overall WAS_d_lower fit and the multi-week event study. The library also supports several extensions we did not demonstrate here.\n", "\n", "- **Population-weighted (survey-aware) inference**: when some markets or regions carry more weight than others - e.g., DMAs weighted by population - HAD accepts a `SurveyDesign` object via the `survey_design=` kwarg on the same `fit()` interface. [Tutorial 22](22_had_survey_design.ipynb) walks the BRFSS-shape survey-design path end-to-end including the pretest workflow.\n", - "- **Composite pretest workflow**: HAD ships a `did_had_pretest_workflow` that combines the QUG support-infimum test (`H0: d_lower = 0`, which adjudicates between the `continuous_at_zero` and `continuous_near_d_lower` design paths) with linearity tests (Stute and Yatchew-HR). On the two-period (`aggregate='overall'`) path this workflow checks QUG and linearity only; the parallel-trends step is closed by the multi-period (`aggregate='event_study'`) joint variants (`stute_joint_pretest`, `joint_pretrends_test`, `joint_homogeneity_test`). The visual placebo check we used in the Multi-Week Event Study is a parallel-trends sanity check, not a substitute for the formal joint pretests; see [Tutorial 21](21_had_pretest_workflow.ipynb) for an end-to-end pretest walkthrough.\n", + "- **Composite pretest workflow**: HAD ships a `did_had_pretest_workflow` that combines the QUG support-infimum test (`H0: d_lower = 0`, which adjudicates between the `continuous_at_zero` and `continuous_near_d_lower` design paths) with linearity tests (Stute and Yatchew-HR). On the two-period overall path this workflow checks QUG and linearity only; the parallel-trends step is closed by the multi-period event-study battery's joint variants (`stute_joint_pretest`, `joint_pretrends_test`, `joint_homogeneity_test`). The visual placebo check we used in the Multi-Week Event Study is a parallel-trends sanity check, not a substitute for the formal joint pretests; see [Tutorial 21](21_had_pretest_workflow.ipynb) for an end-to-end pretest walkthrough.\n", "- **`continuous_at_zero` design path**: if the lightest-touch DMA had no regional add-on (spend exactly $0), HAD switches to the Design 1' identification path with target `WAS` instead of `WAS_d_lower`. The auto-detection picks it up.\n", "- **Mass-point design path**: if a meaningful chunk of DMAs sit at exactly the same minimum spend (rather than spread continuously near the boundary), HAD switches to a 2SLS estimator with matching identification logic. Auto-detected as well.\n", "\n", diff --git a/docs/tutorials/21_had_pretest_workflow.ipynb b/docs/tutorials/21_had_pretest_workflow.ipynb index 06a9b416..baf64cf5 100644 --- a/docs/tutorials/21_had_pretest_workflow.ipynb +++ b/docs/tutorials/21_had_pretest_workflow.ipynb @@ -9,7 +9,7 @@ "\n", "[Tutorial 20](20_had_brand_campaign.ipynb) fit `HeterogeneousAdoptionDiD` (HAD) on a regional brand-campaign panel and reported a per-dollar lift, with a brief visual placebo check at the end. We deliberately deferred the **formal pre-test workflow** to this tutorial, with a forward pointer in T20's \"Extensions\" section.\n", "\n", - "This tutorial picks up where T20 left off. We re-run the brand campaign on a panel close in shape to T20's, then walk through HAD's composite pre-test workflow `did_had_pretest_workflow` and read the diagnostics for paper Section 4.2 of de Chaisemartin, Ciccia, D'Haultfoeuille, & Knau (2026). We start with the two-period (`aggregate=\"overall\"`) workflow, observe that it does not run the parallel pre-trends step, and then **upgrade** to the multi-period (`aggregate=\"event_study\"`) workflow that adds the joint Stute pre-trends and joint homogeneity diagnostics. None of the diagnostics in this tutorial reject; we walk through what that does and does not let us conclude. A side panel compares the two `null=` modes of the Yatchew-HR test, including the recently-shipped `null=\"mean_independence\"` mode (R-parity with `YatchewTest::yatchew_test(order=0)`).\n" + "This tutorial picks up where T20 left off. We re-run the brand campaign on a panel close in shape to T20's, then walk through HAD's composite pre-test workflow `did_had_pretest_workflow` and read the diagnostics for paper Section 4.2 of de Chaisemartin, Ciccia, D'Haultfoeuille, & Knau (2026). We start with the two-period overall workflow, observe that it does not run the parallel pre-trends step, and then **upgrade** to the multi-period event-study workflow that adds the joint Stute pre-trends and joint homogeneity diagnostics. None of the diagnostics in this tutorial reject; we walk through what that does and does not let us conclude. A side panel compares the two `null=` modes of the Yatchew-HR test, including the recently-shipped `null=\"mean_independence\"` mode (R-parity with `YatchewTest::yatchew_test(order=0)`).\n" ] }, { @@ -123,7 +123,7 @@ "source": [ "## Step 1: The Overall Workflow (Two-Period Path)\n", "\n", - "T20's headline used a two-period collapse of the panel - average pre-launch outcome per DMA against average post-launch outcome per DMA. That's also the natural input shape for HAD's two-period (`aggregate=\"overall\"`) pre-test workflow, which runs **paper Step 1 (QUG) + paper Step 3 (linearity, via Stute and Yatchew-HR)**. Step 2 (parallel pre-trends) is not implemented on this path - a single pre-period structurally can't support a pre-trends test - and the workflow's verdict says so explicitly.\n", + "T20's headline used a two-period collapse of the panel - average pre-launch outcome per DMA against average post-launch outcome per DMA. That's also the natural input shape for HAD's two-period overall pre-test workflow, which runs **paper Step 1 (QUG) + paper Step 3 (linearity, via Stute and Yatchew-HR)**. Step 2 (parallel pre-trends) is not implemented on this path - a single pre-period structurally can't support a pre-trends test - and the workflow's verdict says so explicitly.\n", "\n", "We collapse to two periods (pre = avg over weeks 1-4, post = avg over weeks 5-8), then call the workflow.\n" ] @@ -178,7 +178,6 @@ " alpha=0.05,\n", " n_bootstrap=999,\n", " seed=21,\n", - " aggregate=\"overall\",\n", ")\n", "\n", "print(overall_report.verdict)\n", @@ -288,7 +287,7 @@ "\n", "The two-period workflow ran Steps 1 and 3 but did not run Step 2 (parallel pre-trends). Our panel actually has 8 weeks - that is enough pre-periods to add the joint Stute pre-trends diagnostic (paper Section 4.2 step 2 + Hlavka-Huskova 2020 / Delgado-Manteiga 2001 dependence-preserving Mammen multiplier bootstrap).\n", "\n", - "We pass the full multi-period panel to `did_had_pretest_workflow(aggregate=\"event_study\", ...)`. The dispatch runs all three testable steps in one call:\n", + "We pass the full multi-period panel to `did_had_pretest_workflow(...)` (the multi-period panel selects the event-study battery). The dispatch runs all three testable steps in one call:\n", "\n", "- **Step 1**: QUG re-runs on the dose distribution at the treatment period `F` (deterministic; same numbers as the overall path).\n", "- **Step 2**: `joint_pretrends_test` - mean-independence joint Stute over the pre-period horizons (`E[Y_t - Y_base | D] = mu_t` for each t < F).\n", @@ -334,7 +333,6 @@ " alpha=0.05,\n", " n_bootstrap=999,\n", " seed=21,\n", - " aggregate=\"event_study\",\n", ")\n", "\n", "print(es_report.verdict)\n", @@ -597,8 +595,8 @@ "## Summary Checklist\n", "\n", "- HAD's pre-test workflow `did_had_pretest_workflow` bundles paper Section 4.2 Steps 1 (QUG support infimum), 2 (joint Stute pre-trends - event-study path only), and 3 (Stute / Yatchew-HR linearity, joint variant on event-study path).\n", - "- The two-period (`aggregate=\"overall\"`) path runs Steps 1 + 3 only - it cannot run Step 2 because a single pre-period structurally has nothing to test against. The verdict says so verbatim: \"Assumption 7 pre-trends test NOT run\".\n", - "- Upgrade to the multi-period (`aggregate=\"event_study\"`) path to add the joint Stute pre-trends and joint homogeneity diagnostics. The verdict then reads \"TWFE admissible under Section 4 assumptions\" when none of the three testable diagnostics rejects - that is non-rejection evidence under finite-sample power and test specification, not proof.\n", + "- The two-period overall path runs Steps 1 + 3 only - it cannot run Step 2 because a single pre-period structurally has nothing to test against. The verdict says so verbatim: \"Assumption 7 pre-trends test NOT run\".\n", + "- Upgrade to the multi-period event-study path to add the joint Stute pre-trends and joint homogeneity diagnostics. The verdict then reads \"TWFE admissible under Section 4 assumptions\" when none of the three testable diagnostics rejects - that is non-rejection evidence under finite-sample power and test specification, not proof.\n", "- Paper Step 4 is the **decision rule** (if Steps 1-3 don't reject, use TWFE), not a non-testable assumption. The non-testable identification caveat is design-path-specific: **Assumption 3** (boundary regularity at zero) for `continuous_at_zero` (Design 1', T21), or **Assumptions 5/6** for the Design 1 paths (`continuous_near_d_lower` / `mass_point`, T20).\n", "- The Yatchew-HR test exposes two null modes: `null=\"linearity\"` (paper Theorem 7, default; what the workflow calls under the hood) and `null=\"mean_independence\"` (Phase 4 R-parity with R `YatchewTest::yatchew_test(order=0)`, useful on placebo pre-period data).\n", "- QUG fail-to-reject means the data are statistically consistent with `d_lower = 0`; it does not prove the true support starts at zero. The QUG test and HAD's `design=\"auto\"` selector are independent rules: QUG is a statistical test on `H0: d_lower = 0`; `design=\"auto\"` calls `_detect_design()` which uses a min/median heuristic on the dose vector. Both pointed to `continuous_at_zero` on this panel; finite-sample uncertainty in either decision is a remaining caveat.\n", @@ -627,4 +625,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/tutorials/22_had_survey_design.ipynb b/docs/tutorials/22_had_survey_design.ipynb index d1f8d96d..30438310 100644 --- a/docs/tutorials/22_had_survey_design.ipynb +++ b/docs/tutorials/22_had_survey_design.ipynb @@ -479,7 +479,6 @@ " time=\"week\",\n", " unit=\"state_id\",\n", " first_treat=\"first_treat\",\n", - " aggregate=\"event_study\",\n", " )\n", " survey_es_for_ratio = HAD(design=\"auto\").fit(\n", " panel,\n", @@ -488,7 +487,6 @@ " time=\"week\",\n", " unit=\"state_id\",\n", " first_treat=\"first_treat\",\n", - " aggregate=\"event_study\",\n", " survey_design=sd,\n", " )\n", "\n", @@ -512,12 +510,16 @@ "Appendix B.2 the event-study uses `D_{g,F}` (the period-F dose) as\n", "the SAME dose regressor for every horizon - pre-period placebos and\n", "post-period horizons alike. Per-horizon SEs differ because the\n", - "outcome side `ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` does. Pre-period\n", - "placebos have small `ΔY` (no treatment signal — just within-pre noise\n", - "between t and F-1) so the local-linear at d_lower fits low residual\n", - "variance and reads small SEs. Post-period horizons have `ΔY` that\n", - "scales with the treatment effect (slope * `D_{g,F}` plus noise), so\n", - "the local-linear residual variance is larger and the SE is larger.\n", + "outcome side `ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` does. The fitted\n", + "treatment signal itself (`slope * D_{g,F}`) is conditional-mean\n", + "structure the local-linear boundary fit absorbs - in this linear DGP\n", + "it does not add residual variance. What moves the per-horizon SEs is\n", + "the error side: each horizon differences a different period's\n", + "idiosyncratic noise and PSU x period shock draws against the same\n", + "F-1 anchor, and those horizon-specific errors - their conditional\n", + "variance, their interaction with boundary leverage (which units sit\n", + "near d_lower), and the survey influence-function clustering - vary\n", + "across horizons.\n", "The survey machinery folds PSU clustering into both surfaces and\n", "produces a moderate per-horizon inflation on top. The takeaway for the practitioner: **for HAD specifically, do\n", "not eyeball SE inflation against your DEFF expectation from\n", @@ -533,7 +535,7 @@ "source": [ "## Event-study under the survey design\n", "\n", - "Refit with `aggregate=\"event_study\"` and `cband=True` to get\n", + "Refit on the multi-period panel (the event-study mode) with `cband=True` to get\n", "per-horizon ATT estimates plus a sup-t confidence band that adjusts\n", "for the multiple-horizon comparison. The cband is computed via a\n", "multiplier bootstrap that aggregates the per-PSU IF tensor under\n", @@ -563,7 +565,6 @@ " time=\"week\",\n", " unit=\"state_id\",\n", " first_treat=\"first_treat\",\n", - " aggregate=\"event_study\",\n", " survey_design=sd,\n", " cband=True,\n", " )\n", @@ -618,16 +619,17 @@ "metadata": {}, "source": [ "**Reading the event-study.** Pre-launch horizons (e in\n", - "{-4, -3, -2}) cover zero - no pre-trends - and the post-launch\n", + "{-4, -3, -2}) include zero - no detected pre-trend - and the post-launch\n", "horizons (e in {0, 1, 2, 3}) sit on the true per-$1K slope of 100\n", "with both pointwise and sup-t coverage. The sup-t band is a few\n", "percent wider than the pointwise interval; that gap is the multiple-\n", "horizon multiplicity correction. Per-horizon SEs are noticeably\n", - "larger post-launch than pre because `ΔY_{g,t}` post-launch carries\n", - "the treatment-effect contribution (cross-unit variation in\n", - "`slope * D_{g,F}`), driving larger local-linear residual variance\n", - "than the pre-period placebos see (placebos use the same `D_{g,F}`\n", - "regressor, but `ΔY_{g,t}` is noise-only — see the SE-inflation section).\n" + "larger post-launch than pre in this draw; the difference comes from\n", + "the error side - horizon-specific noise and PSU x period shock draws,\n", + "their conditional variance, boundary leverage, and the survey\n", + "influence-function clustering - not from the fitted treatment signal\n", + "itself, which the local-linear boundary fit absorbs into the\n", + "conditional mean (see the SE-inflation section).\n" ] }, { @@ -672,7 +674,6 @@ " time=\"period\",\n", " unit=\"state_id\",\n", " survey_design=sd,\n", - " aggregate=\"overall\",\n", " n_bootstrap=N_BOOTSTRAP,\n", " seed=WORKFLOW_SEED,\n", " )\n", @@ -723,7 +724,6 @@ " unit=\"state_id\",\n", " first_treat=\"first_treat\",\n", " survey_design=sd,\n", - " aggregate=\"event_study\",\n", " n_bootstrap=N_BOOTSTRAP,\n", " seed=WORKFLOW_SEED,\n", " )\n", @@ -856,4 +856,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index 19c1d0ab..4adbb65b 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -106,8 +106,8 @@ Practitioner walkthrough for measuring per-dollar lift when every market is trea ### 21. HAD Pre-test Workflow (`21_had_pretest_workflow.ipynb`) Composite pre-test walkthrough for `HeterogeneousAdoptionDiD`, building on Tutorial 20's brand-campaign framing on a panel where the dose distribution has a strictly positive but very near-zero lower bound (so the QUG step fails-to-reject `H0: d_lower = 0`): - Paper Section 4.2 step taxonomy (QUG support-infimum, parallel pre-trends, linearity) -- `did_had_pretest_workflow(aggregate="overall")` on a two-period collapse: Step 1 + Step 3 only, verdict explicitly flags Step 2 as deferred -- Upgrade to `did_had_pretest_workflow(aggregate="event_study")` on the multi-week panel: adds the joint pre-trends Stute and joint homogeneity Stute diagnostics (none of the three testable steps reject) +- `did_had_pretest_workflow` on a two-period collapse (the overall battery, selected from the panel shape): Step 1 + Step 3 only, verdict explicitly flags Step 2 as deferred +- Upgrade to the multi-week panel (the event-study battery): adds the joint pre-trends Stute and joint homogeneity Stute diagnostics (none of the three testable steps reject) - Side panel comparing `yatchew_hr_test` `null="linearity"` (default, paper Theorem 7) vs `null="mean_independence"` (Phase 4 R-parity with R `YatchewTest::yatchew_test(order=0)`) - Companion drift-test file (`tests/test_t21_had_pretest_workflow_drift.py`) diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index fec662f0..fa9311d5 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -226,7 +226,7 @@ rows: warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/staggered.py, diff_diff/staggered_results.py, diff_diff/aggregation.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt] - 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); CallawaySantAnna's SUPPORTED SUBSET is simple|event_study|group - it has no calendar aggregator (the DEFERRED 'Calendar-time aggregation' row), and aggregate('calendar') raises naming what is supported." + 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); CallawaySantAnna's SUPPORTED SUBSET is simple|event_study|group - it has no calendar aggregator (the DEFERRED 'Calendar-time aggregation' row), and aggregate('calendar') raises naming what is supported. BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column, while the recompute levels (event_study/group) fail closed pending draw retention (BootstrapReplaySpec is the TODO row) - the per-level policy converged with [M-027] (previously ALL levels failed closed; the relay never publishes an analytical df beside percentile inference)." - id: M-021 kind: param group: aggregate-postfit @@ -240,7 +240,7 @@ rows: warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/imputation.py, diff_diff/imputation_aggregation.py, diff_diff/imputation_results.py, diff_diff/imputation_bootstrap.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-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-118], warn-and-still-work; the imputation_did wrapper forwards the sentinel so a plain wrapper call never warns). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary - a behavior improvement). The successor is a PANEL-BACKED lazy recompute kit: ES/group aggregation is a target-specific Theorem-3 recompute from the working panel + untreated FE model (no compact influence payload can honor a different balance_e), so the kit's bookkeeping holds REFERENCES to the SAME per-fit objects self._fit_data already retains for pretrend_test() - ZERO marginal memory and unchanged pickles via memoization (the _estimator_ref field already ships the panel; enumeration in the REGISTRY ImputationDiD Note). Value snapshots (treatment_groups copy, config scalars, a dataclasses.replace copy of survey_metadata, and the survey_df_seed/survey_df_final df channels) isolate recompute and the ES carrier from public-field mutation; aggregate() reads NOTHING mutable off the results object except the deliberate overall-quintet relay and the bootstrap_results gate. SUPPORTED SUBSET simple|event_study|group; calendar/'all' fail closed via the mixin; weights= rejected. Bootstrap fits fail closed for ALL levels including 'simple' (uniform CS/EDiD parity; the per-target psi machinery makes replay tractable - a TODO row). Replicate-weight fits REPLAY the extracted _replicate_override_aggregates with a LEVEL-MATCHED stack ([overall, ES] or [overall, groups]): compute_replicate_refit_variance validates replicates jointly, so aggregate(L) reproduces fit(aggregate=L) exactly and a fit(aggregate='all') surface is NOT the equivalence target when a replicate NaNs on one family's targets; the same joint-stack coupling means migrating a replicate fit from fit(aggregate=) to plain fit changes the OVERALL row's se/CI/df on degenerate designs (documented migration delta, CHANGELOG + REGISTRY note (e) + a contract-test pin). pretrends=True + replicate: post-fit aggregate('event_study') raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); group/simple still work. Simple relay: n = n_treated_obs with n_kind='obs' (the treated/control unit sets OVERLAP, so the CS/EDiD disjoint-units convention cannot apply - the StackedDiD carve-out class); df = the survey_df_final snapshot (what the stored overall inference received). Group rows record per-row df_used at each safe_inference call (additive row-dict key; the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via .get). The M-127 df_convention inert-config warning predicate is REVISED to reachability (pretrends AND not-replicate AND (deprecated fit-time ES/all supplied OR n_bootstrap <= 0)) because post-fit aggregate() made the old aggregate-keyed claim false; reachability-BASED, not exact - a fit whose bootstrap later fails (bootstrap_results=None) can still aggregate post-fit, so that corner warns spuriously (recorded on M-127 too). Container admission NOT widened: ImputationDiD is rejected BY DESIGN (no joint ES covariance - per-horizon conservative SEs only; see M-093). balance_e moves as its own row [M-118]." + notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-118], warn-and-still-work; the imputation_did wrapper forwards the sentinel so a plain wrapper call never warns). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary - a behavior improvement). The successor is a PANEL-BACKED lazy recompute kit: ES/group aggregation is a target-specific Theorem-3 recompute from the working panel + untreated FE model (no compact influence payload can honor a different balance_e), so the kit's bookkeeping holds REFERENCES to the SAME per-fit objects self._fit_data already retains for pretrend_test() - ZERO marginal memory and unchanged pickles via memoization (the _estimator_ref field already ships the panel; enumeration in the REGISTRY ImputationDiD Note). Value snapshots (treatment_groups copy, config scalars, a dataclasses.replace copy of survey_metadata, and the survey_df_seed/survey_df_final df channels) isolate recompute and the ES carrier from public-field mutation; aggregate() reads NOTHING mutable off the results object except the deliberate overall-quintet relay and the bootstrap_results gate. SUPPORTED SUBSET simple|event_study|group; calendar/'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' RELAYS the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (the per-target psi machinery makes replay tractable - a TODO row). The prior fail-closed-for-ALL-levels uniform-parity decision was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale - never publish analytical provenance beside percentile inference - is honored by the NaN df column. Replicate-weight fits REPLAY the extracted _replicate_override_aggregates with a LEVEL-MATCHED stack ([overall, ES] or [overall, groups]): compute_replicate_refit_variance validates replicates jointly, so aggregate(L) reproduces fit(aggregate=L) exactly and a fit(aggregate='all') surface is NOT the equivalence target when a replicate NaNs on one family's targets; the same joint-stack coupling means migrating a replicate fit from fit(aggregate=) to plain fit changes the OVERALL row's se/CI/df on degenerate designs (documented migration delta, CHANGELOG + REGISTRY note (e) + a contract-test pin). pretrends=True + replicate: post-fit aggregate('event_study') raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); group/simple still work. Simple relay: n = n_treated_obs with n_kind='obs' (the treated/control unit sets OVERLAP, so the CS/EDiD disjoint-units convention cannot apply - the StackedDiD carve-out class); df = the survey_df_final snapshot (what the stored overall inference received). Group rows record per-row df_used at each safe_inference call (additive row-dict key; the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via .get). The M-127 df_convention inert-config warning predicate is REVISED to reachability (pretrends AND not-replicate AND (deprecated fit-time ES/all supplied OR n_bootstrap <= 0)) because post-fit aggregate() made the old aggregate-keyed claim false; reachability-BASED, not exact - a fit whose bootstrap later fails (bootstrap_results=None) can still aggregate post-fit, so that corner warns spuriously (recorded on M-127 too). Container admission NOT widened: ImputationDiD is rejected BY DESIGN (no joint ES covariance - per-horizon conservative SEs only; see M-093). balance_e moves as its own row [M-118]." - id: M-022 kind: param group: aggregate-postfit @@ -254,7 +254,7 @@ rows: warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/two_stage.py, diff_diff/two_stage_aggregation.py, diff_diff/two_stage_results.py, diff_diff/two_stage_bootstrap.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-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-119], warn-and-still-work; the two_stage_did wrapper forwards the sentinel so a plain wrapper call never warns). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary). The successor is a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich, so the kit retains a COLUMN-SUBSET COPY of the working frame (only the columns the moved methods read by name, deduplicated - cluster= may legally name the unit/time/first_treat column) plus the Stage-1 FE model, masks, and survey objects. MEMORY CONTRACT: this is the FIRST panel retention on TwoStageDiD results - O(n_obs) incl. unit/time/cluster identifier columns on every results object and pickle, and replicate designs additionally retain the (n_obs x R) replicate matrix via resolved_survey; the CS/EDiD identifier-minimization guarantee deliberately does NOT hold (a store_kit opt-out is a DEFERRED row). score_pad_mask/cluster_ids_full are stored as the Wave-E.3-GATED values fit actually passed. Value snapshots (treatment_groups copy, overall_att, survey_df_stage2/survey_df_final, a dataclasses.replace copy of survey_metadata) isolate recompute and the ES carrier from public-field mutation. SUPPORTED SUBSET simple|event_study|group; calendar/'all' fail closed; weights= rejected. Bootstrap fits fail closed for ALL levels including 'simple' (per-level GMM scores are function-locals; replay is a TODO row); a fit whose bootstrap FAILED (bootstrap_results=None, analytical inference retained) aggregates normally. Replicate-weight fits REPLAY the extracted _replay_replicate_inference with a LEVEL-MATCHED stack - aggregate(L) reproduces fit(aggregate=L) exactly; fit(aggregate='all') is NOT the equivalence target on degenerate designs, and the same joint-stack coupling makes the migration to plain fit change the OVERALL row's se/CI/df there (documented migration delta). Post-fit aggregate('event_study') reproduces the M-092 container contract exactly: analytical fits thread the recomputed joint vcov + vcov_index + the finite-and->0 df scalar through the carrier; replicate fits thread vcov=None/index=None with the REPLAYED level-matched df. Simple relay: n = n_treated_obs with n_kind='obs' (overlapping unit sets - StackedDiD carve-out class); df = the survey_df_final snapshot (on replicate fits that value came from the [overall]-only stack - snapshotted, never re-derived). Group relay df is a SCALAR broadcast (deliberate divergence from ImputationDiD's per-row df_used: _stage2_group passes one immutable survey_df to every row, so the scalar is provenance-exact by construction and the moved method stays verbatim). Container admission NOT widened: DEFERRED pending a normalization derivation - analytical surfaces carry the real joint Gardner-GMM covariance (M-092), but pre-period coefficients are stage-1 residual means, not reference-normalized contrasts, while HonestDiD's Delta arithmetic hard-codes delta_0=0 (see M-093 + the DEFERRED.md paper-gated row). balance_e moves as its own row [M-119]." + notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-119], warn-and-still-work; the two_stage_did wrapper forwards the sentinel so a plain wrapper call never warns). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary). The successor is a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich, so the kit retains a COLUMN-SUBSET COPY of the working frame (only the columns the moved methods read by name, deduplicated - cluster= may legally name the unit/time/first_treat column) plus the Stage-1 FE model, masks, and survey objects. MEMORY CONTRACT: this is the FIRST panel retention on TwoStageDiD results - O(n_obs) incl. unit/time/cluster identifier columns on every results object and pickle, and replicate designs additionally retain the (n_obs x R) replicate matrix via resolved_survey; the CS/EDiD identifier-minimization guarantee deliberately does NOT hold (a store_kit opt-out is a DEFERRED row). score_pad_mask/cluster_ids_full are stored as the Wave-E.3-GATED values fit actually passed. Value snapshots (treatment_groups copy, overall_att, survey_df_stage2/survey_df_final, a dataclasses.replace copy of survey_metadata) isolate recompute and the ES carrier from public-field mutation. SUPPORTED SUBSET simple|event_study|group; calendar/'all' fail closed; weights= rejected. Bootstrap fits: 'simple' RELAYS the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform fail-closed decision superseded 2026-08-05 with the [M-027] per-level convergence; a fit whose bootstrap FAILED (bootstrap_results=None, analytical inference retained) aggregates normally. Replicate-weight fits REPLAY the extracted _replay_replicate_inference with a LEVEL-MATCHED stack - aggregate(L) reproduces fit(aggregate=L) exactly; fit(aggregate='all') is NOT the equivalence target on degenerate designs, and the same joint-stack coupling makes the migration to plain fit change the OVERALL row's se/CI/df there (documented migration delta). Post-fit aggregate('event_study') reproduces the M-092 container contract exactly: analytical fits thread the recomputed joint vcov + vcov_index + the finite-and->0 df scalar through the carrier; replicate fits thread vcov=None/index=None with the REPLAYED level-matched df. Simple relay: n = n_treated_obs with n_kind='obs' (overlapping unit sets - StackedDiD carve-out class); df = the survey_df_final snapshot (on replicate fits that value came from the [overall]-only stack - snapshotted, never re-derived). Group relay df is a SCALAR broadcast (deliberate divergence from ImputationDiD's per-row df_used: _stage2_group passes one immutable survey_df to every row, so the scalar is provenance-exact by construction and the moved method stays verbatim). Container admission NOT widened: DEFERRED pending a normalization derivation - analytical surfaces carry the real joint Gardner-GMM covariance (M-092), but pre-period coefficients are stage-1 residual means, not reference-normalized contrasts, while HonestDiD's Delta arithmetic hard-codes delta_0=0 (see M-093 + the DEFERRED.md paper-gated row). balance_e moves as its own row [M-119]." - id: M-023 kind: param group: aggregate-postfit @@ -268,7 +268,7 @@ rows: 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_results.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-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; calendar and 'all' fail closed via the mixin; weights= rejected. Bootstrap fits fail closed for ALL levels - ES/group because per-horizon draws are not retained (exact-replay wiring is a TODO row), 'simple' for uniform CS-parity conservatism BY DECISION (the stored overall row could relay, as dCDH's M-026 permits for its view-only aggregate, but EDiD keeps one uniform bootstrap rule so no level publishes analytical-provenance fields beside percentile inference). 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: "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; calendar and 'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' RELAYS the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column, while the recompute levels (ES/group) fail closed - per-horizon draws are not retained (exact-replay wiring is a TODO row). 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 relay's NaN df column. 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 @@ -296,7 +296,7 @@ rows: warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/continuous_did.py, diff_diff/continuous_did_aggregation.py, diff_diff/continuous_did_results.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-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value incl. None warns once, then the legacy routing runs unchanged). Unlike the EfficientDiD/Imputation shims, fit-time VALUE VALIDATION pre-existed and STAYS: unknown strings still raise ValueError after the warning ((None, 'dose', 'eventstudy') only). The no-underscore 'eventstudy' spelling dies with the param in 4.0; aggregate() accepts only the unified vocabulary + 'dose' as this estimator's documented extra level. MIXED VIEW/RECOMPUTE architecture (unique in the register): 'simple' and 'dose' are pure VIEWS over stored public fields - the dose curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by fit (aggregate='dose' was a fit-time no-op) - so both levels are PERMITTED on bootstrap fits (the dCDH deviation from CallawaySantAnna's fail-closed rule, applied PER-LEVEL), relaying stored inference verbatim: the overall rows carry the FINITE safe_inference t fit stores beside percentile p/CI, the dose rows reproduce DoseResponseCurve.to_dataframe (NaN t under bootstrap), and only the df column is uniformly NaN there. 'event_study' is a PRUNED-IF-PAYLOAD kit recompute (see the AggregationKit docstring variant): per-(g,t) O(n_treated+n_control) IF-ingredient arrays + unit-level arrays + the PANEL-LEVEL resolved survey design (on replicate designs the (n_obs x R) replicate matrix rides along - a unit-level collapse was reviewed and declined for verbatim-move safety); no panel data columns and no raw unit identifiers are retained; bootstrap fits get a SCALARS-ONLY kit and the ES route fails closed (NotImplementedError naming the fit-time route / n_bootstrap=0 refit; ContinuousDiDResults has no bootstrap_results field, so the config gate is the honest one). Replicate-weight designs ARE supported post-fit (IF-based compute_replicate_if_variance - no refit replay, the contrast with M-021/M-022). Fit-faithful quirk: empty-post_gt fits leave ES rows at NaN inference on both routes (has_post_cells flag). CONTAINER SHAPES: simple = 2 rows (targets att/acrt - the dual-estimand case the AggregationResult target column exists for; n = disjoint treated+control units total, n_kind='units'); dose = 2N target-discriminated rows (labels = the dose grid twice; n NaN / n_kind None / weight None - grid evaluation points carry no count or mass); df from the stored dose_response_att.df_survey channel (finite-and->0 else NaN; the raw stored value incl. the replicate 0-sentinel feeds the to_dataframe-exact t/p derivation). Ships the FIRST heterogeneous-target container, with the AggregationResult summary()/to_dataframe rendering amendment (target column + neutral estimate heading when targets mixed; FIRST-APPEARANCE target-block ordering, labels ascending within block under the _sortable guard; uniform-target producers byte-stable) - normative rule in v4-design section 6. Supported subset simple|event_study|dose - group/calendar fail closed via the mixin; balance_e applies to no level (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. Admission: honest/pretrends containers rejected BY DESIGN (no joint ES covariance; bins not reference-normalized - see M-093). Bootstrap-ES post-fit replay is the TODO.md row." + notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value incl. None warns once, then the legacy routing runs unchanged). Unlike the EfficientDiD/Imputation shims, fit-time VALUE VALIDATION pre-existed and STAYS: unknown strings still raise ValueError after the warning ((None, 'dose', 'eventstudy') only). The no-underscore 'eventstudy' spelling dies with the param in 4.0; aggregate() accepts only the unified vocabulary + 'dose' as this estimator's documented extra level. MIXED VIEW/RECOMPUTE architecture (unique in the register): 'simple' and 'dose' are pure VIEWS over stored public fields - the dose curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by fit (aggregate='dose' was a fit-time no-op) - so both levels are PERMITTED on bootstrap fits (the library-wide per-level relay rule, since [M-027] converged CS/EDiD/Imputation/TwoStage onto it), relaying stored inference verbatim: the overall rows carry the FINITE safe_inference t fit stores beside percentile p/CI, the dose rows reproduce DoseResponseCurve.to_dataframe (NaN t under bootstrap), and only the df column is uniformly NaN there. 'event_study' is a PRUNED-IF-PAYLOAD kit recompute (see the AggregationKit docstring variant): per-(g,t) O(n_treated+n_control) IF-ingredient arrays + unit-level arrays + the PANEL-LEVEL resolved survey design (on replicate designs the (n_obs x R) replicate matrix rides along - a unit-level collapse was reviewed and declined for verbatim-move safety); no panel data columns and no raw unit identifiers are retained; bootstrap fits get a SCALARS-ONLY kit and the ES route fails closed (NotImplementedError naming the fit-time route / n_bootstrap=0 refit; ContinuousDiDResults has no bootstrap_results field, so the config gate is the honest one). Replicate-weight designs ARE supported post-fit (IF-based compute_replicate_if_variance - no refit replay, the contrast with M-021/M-022). Fit-faithful quirk: empty-post_gt fits leave ES rows at NaN inference on both routes (has_post_cells flag). CONTAINER SHAPES: simple = 2 rows (targets att/acrt - the dual-estimand case the AggregationResult target column exists for; n = disjoint treated+control units total, n_kind='units'); dose = 2N target-discriminated rows (labels = the dose grid twice; n NaN / n_kind None / weight None - grid evaluation points carry no count or mass); df from the stored dose_response_att.df_survey channel (finite-and->0 else NaN; the raw stored value incl. the replicate 0-sentinel feeds the to_dataframe-exact t/p derivation). Ships the FIRST heterogeneous-target container, with the AggregationResult summary()/to_dataframe rendering amendment (target column + neutral estimate heading when targets mixed; FIRST-APPEARANCE target-block ordering, labels ascending within block under the _sortable guard; uniform-target producers byte-stable) - normative rule in v4-design section 6. Supported subset simple|event_study|dose - group/calendar fail closed via the mixin; balance_e applies to no level (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. Admission: honest/pretrends containers rejected BY DESIGN (no joint ES covariance; bins not reference-normalized - see M-093). Bootstrap-ES post-fit replay is the TODO.md row." - id: M-026 kind: param group: aggregate-postfit @@ -310,7 +310,7 @@ rows: 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 fail closed via the mixin message; balance_e applies to no level here (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. BOOTSTRAP FITS ARE PERMITTED, deviating from CallawaySantAnna's fail-closed rule, 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: "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 fail closed via the mixin message; 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 @@ -319,11 +319,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning - code_refs: [diff_diff/had.py] - notes: "HAD levels: overall -> simple, event_study unchanged." + 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)." # ---- Contract renames (params) ------------------------------------------- - id: M-030 @@ -989,7 +990,7 @@ rows: phase: 2 test_ref: tests/test_event_study_surface.py code_refs: [diff_diff/results_base.py, diff_diff/__init__.py] - notes: "Phase 2 unified event-study representation (spec section 5): EventStudyResults container + builders for the 14 producers (CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, SpilloverDiD, ContinuousDiD, EfficientDiD, WooldridgeDiD, StaggeredTripleDifference, MultiPeriodDiD, LPDiD, ChaisemartinDHaultfoeuille, HeterogeneousAdoptionDiD). Canonical quintet columns, explicit is_reference marking (successor to the retiring sentinels [M-093]), vcov+vcov_index ordering, cband columns, event_time_convention metadata. Public exposure rides aggregate(type='event_study') in Phase 2 PR (b); merged TWFE returns it in Phase 3 [M-010]. introduced_in gates the 3.9 cut, mirroring [M-091]. Born done in this introducing diff (builder is package-internal; the class is exported). Amended pre-cut (same test_ref, introduced_in 3.9 not yet released): df became PER-ROW (one entry per event time, the df each stored p/CI actually used; joins the pinned to_dataframe schema) and StackedDiD/TwoStageDiD persist their internal full ES VCVs (event_study_vcov/_index/_df container fields; mode-gated for TwoStageDiD bootstrap/replicate). Completed pre-cut by the remaining producer channels: SunAbraham (per-event dict) and de Chaisemartin-D'Haultfoeuille (scalar) event_study_df, plus LPDiD pooled_df for the headline pre/post windows - every producer whose inference records a df now exposes it. Amended pre-cut a second time (2(b) PR-1, with M-026): three optional PROVENANCE fields appended last - base_period, anticipation, and df_survey (the fit's resolved SCALAR inference df beside the per-row channel: survey_metadata.df_survey with replicate-undefined mapping to the 0.0 fail-closed sentinel, else df_inference, else None - the per-row df column cannot encode that sentinel because __post_init__ NaNs it wherever p is non-finite) - threaded by the builders (the _empty_surface early return included) so the container consumers (HonestDiD's universal-base check, PreTrendsPower's anticipation cutoff, the honest df extraction) read fit-faithful values instead of dropping them. Amended pre-cut a third time (same PR): a fourth provenance field reference_event_times (also new on CallawaySantAnnaResults, computed at fit under base_period=universal) - the DISTINCT per-cohort positional-base event times, the common-reference signal is_reference cannot carry on gapped grids where a cohort's base overlaps another cohort's estimated horizon; HonestDiD and PreTrendsPower fail closed on more than one entry, on BOTH input routes (REGISTRY HonestDiD common-reference-guard Note). SCOPE QUALIFIER (2(b) PR-3a, with M-023): the 'every producer whose inference records a df now exposes it' sentence is satisfied on EfficientDiD through the SCALAR df_survey channel only - its newly public aggregate('event_study') container has no per-row df source (no event_study_df/df_inference field; the per-row column is all-NaN, contract-permitted for a producer that records none); threading the retained kit scalar into the per-row channel is the TODO.md M-092-completion row. The qualifier EXTENDS to ImputationDiD (2(b) PR-3b, with M-021): its newly public container likewise exposes only the scalar df_survey channel (no per-row df source; all-NaN per-row column, identical to its fit-time surface) - the same TODO.md completion row names it. The qualifier EXTENDS to ContinuousDiD (2(b) PR-3c, with M-025): its newly public container exposes only the scalar df_survey channel via the carrier's survey_metadata (no event_study_df/df_inference field, so _from_relative_dict publishes an all-NaN per-row df column on survey fits whose ES rows received a finite _survey_df - identical on the fit-time and post-fit routes); the same TODO.md completion row names it." + notes: "Phase 2 unified event-study representation (spec section 5): EventStudyResults container + builders for the 14 producers (CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, SpilloverDiD, ContinuousDiD, EfficientDiD, WooldridgeDiD, StaggeredTripleDifference, MultiPeriodDiD, LPDiD, ChaisemartinDHaultfoeuille, HeterogeneousAdoptionDiD). Canonical quintet columns, explicit is_reference marking (successor to the retiring sentinels [M-093]), vcov+vcov_index ordering, cband columns, event_time_convention metadata. Public exposure rides aggregate(type='event_study') in Phase 2 PR (b); merged TWFE returns it in Phase 3 [M-010]. introduced_in gates the 3.9 cut, mirroring [M-091]. Born done in this introducing diff (builder is package-internal; the class is exported). Amended pre-cut (same test_ref, introduced_in 3.9 not yet released): df became PER-ROW (one entry per event time, the df each stored p/CI actually used; joins the pinned to_dataframe schema) and StackedDiD/TwoStageDiD persist their internal full ES VCVs (event_study_vcov/_index/_df container fields; mode-gated for TwoStageDiD bootstrap/replicate). Completed pre-cut by the remaining producer channels: SunAbraham (per-event dict) and de Chaisemartin-D'Haultfoeuille (scalar) event_study_df, plus LPDiD pooled_df for the headline pre/post windows - every producer whose inference records a df now exposes it. Amended pre-cut a second time (2(b) PR-1, with M-026): three optional PROVENANCE fields appended last - base_period, anticipation, and df_survey (the fit's resolved SCALAR inference df beside the per-row channel: survey_metadata.df_survey with replicate-undefined mapping to the 0.0 fail-closed sentinel, else df_inference, else None - the per-row df column cannot encode that sentinel because __post_init__ NaNs it wherever p is non-finite) - threaded by the builders (the _empty_surface early return included) so the container consumers (HonestDiD's universal-base check, PreTrendsPower's anticipation cutoff, the honest df extraction) read fit-faithful values instead of dropping them. Amended pre-cut a third time (same PR): a fourth provenance field reference_event_times (also new on CallawaySantAnnaResults, computed at fit under base_period=universal) - the DISTINCT per-cohort positional-base event times, the common-reference signal is_reference cannot carry on gapped grids where a cohort's base overlaps another cohort's estimated horizon; HonestDiD and PreTrendsPower fail closed on more than one entry, on BOTH input routes (REGISTRY HonestDiD common-reference-guard Note). SCOPE QUALIFIER (2(b) PR-3a, with M-023): the 'every producer whose inference records a df now exposes it' sentence is satisfied on EfficientDiD through the SCALAR df_survey channel only - its newly public aggregate('event_study') container has no per-row df source (no event_study_df/df_inference field; the per-row column is all-NaN, contract-permitted for a producer that records none); threading the retained kit scalar into the per-row channel is the TODO.md M-092-completion row. The qualifier EXTENDS to ImputationDiD (2(b) PR-3b, with M-021): its newly public container likewise exposes only the scalar df_survey channel (no per-row df source; all-NaN per-row column, identical to its fit-time surface) - the same TODO.md completion row names it. The qualifier EXTENDS to ContinuousDiD (2(b) PR-3c, with M-025): its newly public container exposes only the scalar df_survey channel via the carrier's survey_metadata (no event_study_df/df_inference field, so _from_relative_dict publishes an all-NaN per-row df column on survey fits whose ES rows received a finite _survey_df - identical on the fit-time and post-fit routes); the same TODO.md completion row names it. Amended pre-cut a fourth time (2(b) PR-4, with M-027): an optional 'estimand' provenance field appended last plus a matching per-row 'estimand' column appended to the pinned to_dataframe schema - the per-row estimand discriminator ('att' for every ATT producer; the estimand label 'WAS'/'WAS_d_lower' where the att column is NOT an ATT, relayed by _from_had from target_parameter and honored by summary()'s column heading and to_dict()) - so neither the container nor a detached frame can silently relabel WAS-family numbers as ATT (the AggregationResult.target precedent). The qualifier EXTENDS to HeterogeneousAdoptionDiD (2(b) PR-4, with M-027): its newly public event-study container exposes only the scalar df_survey channel (the _from_had adapter passes no per-row df while survey ES fits pass a finite per-horizon df_infer into every safe_inference call); the same TODO.md completion row names it." - id: M-093 kind: behavior group: results-contract @@ -1000,7 +1001,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, all-NaN per-row df); 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. 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, all-NaN per-row df); 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. 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 @@ -1649,3 +1650,18 @@ rows: test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/diagnostics.py, docs/api/diagnostics.rst, docs/tutorials/04_parallel_trends.ipynb] notes: "Twin of [M-137] - see its notes for the missed-rename rationale and the wrapper-overload deferral." + + - id: M-139 + kind: param + group: aggregate-postfit + old: "diff_diff:did_had_pretest_workflow[aggregate]" + new: null + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: shimmed + 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." diff --git a/docs/v4-design.md b/docs/v4-design.md index e48b523a..833a1f66 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -436,7 +436,9 @@ strongest norm (`did::aggte`, `etwfe::emfx`, Stata `estat aggregation`). **Vocabulary.** Closed set: `"simple"`, `"event_study"`, `"group"`, `"calendar"`, plus per-estimator documented extras where the estimand demands them (ContinuousDiD adds `"dose"` [M-025]; HAD's `"overall"` maps to -`"simple"` [M-027]; Wooldridge's `"gt"` group-time table stays as a +`"simple"` [M-027] - its fit-time mode selector, and the workflow twin +`did_had_pretest_workflow(aggregate=)` [M-139], both resolve by +panel-shape inference; Wooldridge's `"gt"` group-time table stays as a documented extra). The drifted spellings die across ALL their surfaces: `"eventstudy"` (ContinuousDiD [M-025]); Wooldridge's `"event"` on its EXISTING post-fit `aggregate(type=)` - the emfx-style prior art for this @@ -445,7 +447,13 @@ section's pattern - plus `summary(aggregation=)` and **Heterogeneous-`target` rendering** (added with [M-025], the first producer of a mixed-target `AggregationResult` - ContinuousDiD's att/acrt -dual estimand): when a container carries more than one distinct `target`, +dual estimand; widened with [M-027]: the same target-column + neutral +`estimate`-heading rendering also fires when the SINGLE distinct target +is not `"att"` - HAD's WAS/WAS_d_lower and dCDH's estimand-labelled +relays previously rendered under the hard-coded `ATT` heading, a +mislabel; the target column sizes to the longest label so +uniform-`att` and att/acrt producers stay byte-stable): when a +container carries more than one distinct `target`, `summary()` renders a `target` column and a neutral `estimate` heading (the uniform-target `ATT` heading would mislabel the other estimand's rows), and `to_dataframe()` orders rows by FIRST-APPEARANCE target blocks @@ -473,9 +481,16 @@ rather than unit-length, so the O(n_units) figure is a panel-only bound. Raw unit identifiers are NOT retained - the kit needs only position, so it stores canonical 0..n-1 codes, keeping a shared results artifact free of names, emails or administrative IDs. Analytical-vs-bootstrap inference of the aggregated -estimand follows the fit's inference method; where bootstrap draws are not -retained, `aggregate()` on a bootstrapped fit RAISES rather than silently -returning analytical inference. **View-relay exception (Phase 2b PRs 1-2):** +estimand follows the fit's inference method, and the bootstrap gate is +PER-LEVEL (converged with [M-027] across CS/EDiD/Imputation/TwoStage, +which previously failed closed on every level): a level that RELAYS the +fit's stored inference verbatim (`'simple'` on every adopter; all of +dCDH/StackedDiD/HAD) is faithful under any inference regime and stays +available on bootstrapped fits, with the df COLUMN NaN'd there - no df +governs percentile inference, so a relay never publishes analytical +provenance beside percentile statistics; where bootstrap draws are not +retained, a RECOMPUTE level on a bootstrapped fit RAISES rather than +silently returning analytical inference. **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 @@ -684,10 +699,10 @@ above; anything only one PR cares about stays in that PR's plan.** | Phase | Ships in | PRs (each: dedicated shim/removal tests + matrix flips + CHANGELOG naming flipped row ids) | |---|---|---| | 1 (this PR) | - | Spec + matrix + enforcement test + support edits | -| 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] (M-020's shim already shipped); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + the 2(c)-ii missed-rename amendments [M-136..M-138] (LPDiD `level` value; the two post-dummy diagnostics params) + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] | +| 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] [M-139] (M-020's shim already shipped; M-139 is the HAD workflow twin, a pre-cut amendment); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + the 2(c)-ii missed-rename amendments [M-136..M-138] (LPDiD `level` value; the two post-dummy diagnostics params) + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] | | 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] + the fit `time`->`post` rename [M-082] (gates: section 4.1's equivalence/divergence/pooled-parity test triple); (b) TripleDifference facade [M-013] + the SDDD alias [M-064]; (c) CiC method= [M-015] | | 4: release + soak | 3.9 cut | Migration guide written (skeleton: section 10); maintainer cuts 3.9; maint/3.8 rule active | -| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; docs/llms.txt/README refresh | +| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-139, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; docs/llms.txt/README refresh | | 6: front door | 4.1 | `event_study(data, outcome, unit, time, first_treat, estimator=...)` comparison entry point over the staggered family (sketch only; specified in its own plan) | Citation semantic for the table: a cell may cite a row whose current `phase` @@ -717,8 +732,11 @@ re-enumerate the cells' M-id lists: re-init - `type(self)(**merged)` validates before any mutation - so the renames build on a contract that can never drift from `__init__`. 4. 2(c)-ii: the rename sweep (the phase-2(c) cell); may split by rename group. -5. 2(b): post-fit `aggregate()` + `fit(aggregate=)` shims (the (b) cell), - claiming reserved ids M-116/M-118..M-121 for any new rows; the +5. 2(b): post-fit `aggregate()` + `fit(aggregate=)` shims (the (b) cell). + The reserved-id pool is spent for this wave (M-118..M-120 claimed; + M-116/M-121 stay earmarked for the HAD rename and Wooldridge), so any + further new row takes the next free id (M-139, the HAD workflow twin, + was the first); the `EventStudyResults` downstream-consumability work (TODO.md row: the three consumers currently reject the unified container) lands before or inside this wave so the shims do not steer users into a dead end. @@ -751,7 +769,7 @@ not expressible as ledger rows, so the 3.9 release PR asserts them by hand: Everything else queued for 3.9 is row-gated, by one of two mechanisms. Symbol rows that declare a `warning` gate on `deprecated_in` - the shim must have -shipped ([M-010] [M-013] [M-015], [M-020]..[M-027], [M-030]..[M-047], +shipped ([M-010] [M-013] [M-015], [M-020]..[M-027], [M-139], [M-030]..[M-047], [M-070]..[M-077], [M-082], [M-084], [M-086]..[M-089], [M-094] [M-095], [M-097]..[M-115]). Rows with no shim to assert gate @@ -882,14 +900,14 @@ forever - a removed symbol resurrecting is a test failure. class/function rows and alias rows also assert `__all__` membership consistent with their status (stale `import *` entries fail). The shipped row ids are a - committed snapshot in the enforcement test (120 as of 2(b) PR-3b's - Imputation/TwoStage balance_e rows: Phase 1 + the diagnostic-family + committed snapshot in the enforcement test (121 as of 2(b) PR-4's + HAD workflow-aggregate row: Phase 1 + the diagnostic-family amendment + the M-092/M-093 results-contract rows + the M-094..M-096 amendment rows + the M-097..M-115 completeness sweep + M-117..M-120/M-122 + the ETWFE reference-period pair M-123/M-124 + M-125 + M-126 + M-127..M-131 + the alias-diet family M-132..M-135 + the 2(c)-ii amendments - M-136..M-138; + M-136..M-138 + M-139; the snapshot extends by a new id range in the same diff that appends rows): ids are never deleted or reused, and the test fails if any snapshot id disappears. diff --git a/tests/test_aggregate_contract.py b/tests/test_aggregate_contract.py index c76cbc60..96679e98 100644 --- a/tests/test_aggregate_contract.py +++ b/tests/test_aggregate_contract.py @@ -5,7 +5,9 @@ ``fit(aggregate=)`` shims), M-024/M-026 (the Stacked / dCDH shims + view relays), M-025 (the ContinuousDiD shim + MIXED view/recompute aggregate() - 'simple'/'dose' views, 'event_study' pruned-IF-payload kit), -M-117/M-120/M-118/M-119 (``balance_e`` moves onto ``aggregate()``) +M-117/M-120/M-118/M-119 (``balance_e`` moves onto ``aggregate()``), +M-027/M-139 (the HAD fit + pretest-workflow mode-selector shims with +panel-shape inference, and the two PURE-VIEW results classes) and M-122 (``AggregationResult``). The headline gate is NUMERICAL INERTNESS: for every supported type, @@ -252,6 +254,25 @@ def test_identifier_minimization_is_numerically_inert(self, panel, fitted): # --------------------------------------------------------------------------- # +def _assert_bootstrap_simple_relay(res, n_expected=None): + """Shared per-level-policy pin (converged with M-027): on a bootstrapped + fit, aggregate('simple') relays the STORED overall quintet verbatim - + percentile se/p/CI beside the finite ``safe_inference`` t - and only the + df COLUMN is NaN (no df governs percentile inference).""" + agg = res.aggregate("simple") + assert float(agg.att[0]) == res.overall_att + assert float(agg.se[0]) == res.overall_se + assert float(agg.t_stat[0]) == res.overall_t_stat + assert np.isfinite(agg.t_stat[0]) + assert float(agg.p_value[0]) == res.overall_p_value + assert float(agg.conf_int_lower[0]) == res.overall_conf_int[0] + assert float(agg.conf_int_upper[0]) == res.overall_conf_int[1] + assert np.isnan(agg.df[0]) + if n_expected is not None: + assert float(agg.n[0]) == float(n_expected) + return agg + + class TestFailClosed: def test_calendar_unsupported_by_cs(self, fitted): with pytest.raises(ValueError, match="calendar"): @@ -279,15 +300,49 @@ def test_balance_e_rejected_where_inert(self, fitted, level): with pytest.raises(ValueError, match="balance_e"): fitted.aggregate(level, balance_e=2) - def test_bootstrap_fit_raises(self, panel): + def test_bootstrap_recompute_levels_fail_closed(self, panel): """Percentile-bootstrap inference cannot be reproduced from the - analytical state retained here, so aggregate() must not substitute - analytical numbers silently.""" + analytical state retained here, so the RECOMPUTE levels must not + substitute analytical numbers silently.""" with warnings.catch_warnings(): warnings.simplefilter("ignore") boot = CallawaySantAnna(n_bootstrap=49, seed=42).fit(panel, **FIT_KW) - with pytest.raises(NotImplementedError, match="bootstrap"): - boot.aggregate("group") + for level in ("event_study", "group"): + with pytest.raises(NotImplementedError, match="bootstrap") as exc: + boot.aggregate(level) + assert "aggregate('simple') relays" in str(exc.value) + + def test_bootstrap_simple_relays_stored_quintet(self, panel): + """Per-level policy (converged with M-027): 'simple' is a bit-exact + relay of the stored overall row, faithful under the bootstrap regime, + so it stays available with a NaN df column.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + boot = CallawaySantAnna(n_bootstrap=49, seed=42).fit(panel, **FIT_KW) + _assert_bootstrap_simple_relay(boot, n_expected=boot.n_treated_units + boot.n_control_units) + + def test_bootstrap_survey_simple_relay_df_nan(self, panel): + """Survey-PSU multiplier bootstrap: the finite survey metadata rides + the fit, but the relay's df column is still NaN - percentile p is not + governed by a t-reference, so publishing the survey df beside it + would misstate provenance.""" + d = panel.copy() + rng = np.random.default_rng(7) + wmap = {u: rng.uniform(0.5, 2.0) for u in d["unit"].unique()} + d["w"] = d["unit"].map(wmap) + d["psu"] = d["unit"] % 7 + from diff_diff import SurveyDesign + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + boot = CallawaySantAnna(n_bootstrap=49, seed=42).fit( + d, + **FIT_KW, + survey_design=SurveyDesign(weights="w", psu="psu"), + ) + assert boot.survey_metadata is not None + assert boot.survey_metadata.df_survey is not None + _assert_bootstrap_simple_relay(boot) # --------------------------------------------------------------------------- # @@ -1119,6 +1174,8 @@ def test_mixin_hooks_are_not_dataclass_fields(self): assert "ImputationDiDResults" in checked assert "TwoStageDiDResults" in checked assert "ContinuousDiDResults" in checked + assert "HeterogeneousAdoptionDiDResults" in checked + assert "HeterogeneousAdoptionDiDEventStudyResults" in checked # --------------------------------------------------------------------------- # @@ -1840,11 +1897,28 @@ def test_survey_metadata_not_mutated(self): res.aggregate("group") assert res.survey_metadata.df_survey == before - def test_bootstrap_fails_closed_all_levels(self, efficient_panel): + def test_bootstrap_recompute_levels_fail_closed(self, efficient_panel): res = _fit_efficient(efficient_panel, est_kw={"n_bootstrap": 20, "seed": 1}) - for level in ("simple", "event_study", "group"): - with pytest.raises(NotImplementedError, match="bootstrap"): + for level in ("event_study", "group"): + with pytest.raises(NotImplementedError, match="bootstrap") as exc: res.aggregate(level) + assert "aggregate('simple') relays" in str(exc.value) + + def test_bootstrap_simple_relays_stored_quintet(self, efficient_panel): + res = _fit_efficient(efficient_panel, est_kw={"n_bootstrap": 20, "seed": 1}) + _assert_bootstrap_simple_relay( + res, n_expected=res._aggregation_kit.bookkeeping["n_units_total"] + ) + + def test_bootstrap_survey_simple_relay_df_nan(self): + # EDiD supports full TSL survey designs under bootstrap (only + # REPLICATE designs are rejected); the relay must NaN the df column + # beside the finite survey metadata. + d, _ = _efficient_survey_panel() + sd = _efficient_survey_design() + res = _fit_efficient(d, est_kw={"n_bootstrap": 20, "seed": 1}, survey_design=sd) + assert res.survey_metadata is not None + _assert_bootstrap_simple_relay(res) def test_bootstrap_fit_time_group_rows_clear_df_used(self, efficient_panel): res = _fit_efficient( @@ -2324,11 +2398,28 @@ def test_repeated_and_order_independent(self, imputation_fitted): b = imputation_fitted.aggregate("group").to_dataframe() pd.testing.assert_frame_equal(a, b) - def test_bootstrap_fails_closed_all_levels(self, imputation_panel): + def test_bootstrap_recompute_levels_fail_closed(self, imputation_panel): res = _fit_imputation(imputation_panel, est_kw={"n_bootstrap": 19, "seed": 1}) - for level in ("simple", "event_study", "group"): - with pytest.raises(NotImplementedError, match="bootstrap"): + for level in ("event_study", "group"): + with pytest.raises(NotImplementedError, match="bootstrap") as exc: res.aggregate(level) + assert "aggregate('simple') relays" in str(exc.value) + + def test_bootstrap_simple_relays_stored_quintet(self, imputation_panel): + res = _fit_imputation(imputation_panel, est_kw={"n_bootstrap": 19, "seed": 1}) + _assert_bootstrap_simple_relay( + res, n_expected=res._aggregation_kit.bookkeeping["n_treated_obs"] + ) + + def test_bootstrap_survey_simple_relay_df_nan(self): + # TSL survey + bootstrap is a supported combination (only replicate + # designs are rejected under bootstrap); the relay NaNs the df column + # beside the finite survey metadata. + d, _ = _imputation_survey_panel() + sd = _imputation_survey_design() + res = _fit_imputation(d, est_kw={"n_bootstrap": 19, "seed": 1}, survey_design=sd) + assert res.survey_metadata is not None + _assert_bootstrap_simple_relay(res) def test_pretrends_replicate_es_fails_closed(self): d, rep_cols = _imputation_survey_panel(replicate=True) @@ -2785,11 +2876,28 @@ def test_repeated_and_order_independent(self, twostage_fitted): b = twostage_fitted.aggregate("group").to_dataframe() pd.testing.assert_frame_equal(a, b) - def test_bootstrap_fails_closed_all_levels(self, twostage_panel): + def test_bootstrap_recompute_levels_fail_closed(self, twostage_panel): res = _fit_twostage(twostage_panel, est_kw={"n_bootstrap": 19, "seed": 1}) - for level in ("simple", "event_study", "group"): - with pytest.raises(NotImplementedError, match="bootstrap"): + for level in ("event_study", "group"): + with pytest.raises(NotImplementedError, match="bootstrap") as exc: res.aggregate(level) + assert "aggregate('simple') relays" in str(exc.value) + + def test_bootstrap_simple_relays_stored_quintet(self, twostage_panel): + res = _fit_twostage(twostage_panel, est_kw={"n_bootstrap": 19, "seed": 1}) + _assert_bootstrap_simple_relay( + res, n_expected=res._aggregation_kit.bookkeeping["n_treated_obs"] + ) + + def test_bootstrap_survey_simple_relay_df_nan(self): + # TSL survey + bootstrap is a supported combination (only replicate + # designs are rejected under bootstrap); the relay NaNs the df column + # beside the finite survey metadata. + d, _ = _twostage_survey_panel() + sd = _twostage_survey_design() + res = _fit_twostage(d, est_kw={"n_bootstrap": 19, "seed": 1}, survey_design=sd) + assert res.survey_metadata is not None + _assert_bootstrap_simple_relay(res) def test_fail_closed_vocabulary(self, twostage_fitted): for bad in ("calendar", "all", "nonsense"): @@ -3384,3 +3492,468 @@ def _walk(obj): for leaf in _walk({k: v for k, v in bk.items() if k != "survey_metadata"}): assert not isinstance(leaf, pd.DataFrame), "kit retains a DataFrame" assert not isinstance(leaf, pd.Series), "kit retains a Series" + + +# --------------------------------------------------------------------------- # +# HeterogeneousAdoptionDiD (rows M-027/M-139): the fit + workflow MODE-SELECTOR +# shims (panel-shape inference) and the two PURE-VIEW results classes. +# --------------------------------------------------------------------------- # + +HAD_KW = dict(outcome="outcome", dose="dose", time="period", unit="unit") + + +def _had_panel_2p(seed=11, n_units=200, mass_point=False, constant_outcome=False): + """Two-period HAD panel (the 'overall' mode shape).""" + rng = np.random.default_rng(seed) + if mass_point: + d = np.where(rng.uniform(size=n_units) < 0.5, 0.5, rng.uniform(1.0, 2.0, n_units)) + else: + d = rng.uniform(0.1, 2.0, n_units) + rows = [] + for i in range(n_units): + y0 = 0.0 if constant_outcome else rng.normal() + y1 = 0.0 if constant_outcome else 1.5 * d[i] + rng.normal() + rows.append((i, 0, 0.0, y0, i % 5)) + rows.append((i, 1, d[i], y1, i % 5)) + return pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "grp"]) + + +def _had_panel_multi(seed=13, n_units=200, n_periods=5, F=2): + """Multi-period common-adoption HAD panel (the 'event_study' mode shape).""" + rng = np.random.default_rng(seed) + d = rng.uniform(0.1, 2.0, n_units) + rows = [] + for i in range(n_units): + for t in range(n_periods): + dose_it = d[i] if t >= F else 0.0 + rows.append((i, t, dose_it, 1.5 * dose_it + rng.normal(), F)) + return pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "ft"]) + + +def _had_panel_post_filter_too_small(seed=17, n_units=120): + """Staggered T>2 panel whose last-cohort auto-filter leaves < 3 periods. + + Earlier-cohort units (first_treat=2) span periods 1-4; last-cohort + (first_treat=4) and never-treated units are observed ONLY at periods + 3-4. Raw distinct periods = 4 -> the sentinel infers event_study; the + Appendix-B.2 filter keeps only the last cohort + never-treated, whose + observed periods are {3, 4} -> the post-filter shape error fires on a + PLAIN fit (no FutureWarning). + """ + rng = np.random.default_rng(seed) + rows = [] + for i in range(n_units): + kind = i % 3 # 0: early cohort, 1: last cohort, 2: never-treated + if kind == 0: + periods, ft, dose = range(1, 5), 2, rng.uniform(0.5, 2.0) + elif kind == 1: + periods, ft, dose = (3, 4), 4, rng.uniform(0.5, 2.0) + else: + periods, ft, dose = (3, 4), 0, 0.0 + for t in periods: + treated = ft > 0 and t >= ft + d_it = dose if treated else 0.0 + rows.append((i, t, d_it, 0.5 * d_it + rng.normal(), ft)) + return pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "ft"]) + + +def _fit_had(data, *, est_kw=None, **fit_kw): + from diff_diff import HeterogeneousAdoptionDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return HeterogeneousAdoptionDiD(**(est_kw or {})).fit(data, **HAD_KW, **fit_kw) + + +@pytest.fixture(scope="module") +def had_panel_2p(): + return _had_panel_2p() + + +@pytest.fixture(scope="module") +def had_panel_multi(): + return _had_panel_multi() + + +@pytest.fixture(scope="module") +def had_fitted_2p(had_panel_2p): + return _fit_had(had_panel_2p) + + +@pytest.fixture(scope="module") +def had_fitted_multi(had_panel_multi): + return _fit_had(had_panel_multi, first_treat="ft") + + +class TestHadShim: + def test_plain_fit_2p_infers_overall_without_warning(self, had_panel_2p): + from diff_diff import HeterogeneousAdoptionDiD, HeterogeneousAdoptionDiDResults + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = HeterogeneousAdoptionDiD().fit(had_panel_2p, **HAD_KW) + assert isinstance(res, HeterogeneousAdoptionDiDResults) + assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] + + def test_plain_fit_multi_infers_event_study_without_warning(self, had_panel_multi): + # The headline M-027 behavior delta: a plain multi-period fit() + # previously raised the two-period shape error; the sentinel now + # infers the event-study mode (error -> works). + from diff_diff import ( + HeterogeneousAdoptionDiD, + HeterogeneousAdoptionDiDEventStudyResults, + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = HeterogeneousAdoptionDiD().fit(had_panel_multi, **HAD_KW, first_treat="ft") + 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): + 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") + + def test_supplied_overall_on_multi_warns_then_shape_error(self, had_panel_multi): + 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" + ) + + def test_sentinel_post_filter_shape_error_no_warning(self): + # Sentinel-reachable shape error: T>2 raw panel infers event_study, + # then the last-cohort auto-filter drops it below three periods. + # The message must read correctly on a plain fit (no kwarg + # teaching) and no FutureWarning fires. + from diff_diff import HeterogeneousAdoptionDiD + + data = _had_panel_post_filter_too_small() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(ValueError, match="staggered auto-filter"): + HeterogeneousAdoptionDiD().fit(data, **HAD_KW, first_treat="ft") + assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] + + def test_missing_time_column_still_value_error(self, had_panel_2p): + # The inference helper must not regress the tested ValueError to a + # raw KeyError (it dereferences the time column first). + from diff_diff import HeterogeneousAdoptionDiD + + with pytest.raises(ValueError, match="column"): + HeterogeneousAdoptionDiD().fit( + 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 [w for w in caught if issubclass(w.category, FutureWarning)] == [] + + def test_workflow_supplied_mode_warns(self, had_panel_2p): + from diff_diff import did_had_pretest_workflow + + with pytest.warns(FutureWarning, match=r"workflow\(aggregate=\) is deprecated"): + did_had_pretest_workflow( + had_panel_2p, **HAD_KW, n_bootstrap=99, seed=3, aggregate="overall" + ) + + def test_workflow_invalid_value_warns_then_raises(self, had_panel_2p): + 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") + + +class TestHadAggregate: + def _assert_simple_relay(self, res): + agg = res.aggregate("simple") + assert isinstance(agg, AggregationResult) + assert agg.level == "simple" + assert list(agg.label) == ["overall"] + assert list(agg.target) == [res.target_parameter] + for got, want in ( + (agg.att[0], res.att), + (agg.se[0], res.se), + (agg.t_stat[0], res.t_stat), + (agg.p_value[0], res.p_value), + (agg.conf_int_lower[0], res.conf_int[0]), + (agg.conf_int_upper[0], res.conf_int[1]), + ): + assert (float(got) == want) or (np.isnan(got) and np.isnan(want)) + assert float(agg.n[0]) == float(res.n_obs) + assert agg.n_kind == "units" + assert agg.estimator == "HeterogeneousAdoptionDiD" + return agg + + def test_simple_view_bit_exact_continuous(self, had_fitted_2p): + agg = self._assert_simple_relay(had_fitted_2p) + assert had_fitted_2p.target_parameter in ("WAS", "WAS_d_lower") + # Continuous designs: disjoint treated/control split. + assert had_fitted_2p.n_treated + had_fitted_2p.n_control == had_fitted_2p.n_obs + # Plain fit passed df=None into safe_inference: NaN df column. + assert np.isnan(agg.df[0]) + + def test_simple_view_bit_exact_mass_point(self): + res = _fit_had(_had_panel_2p(mass_point=True)) + assert res.design == "mass_point" + # NO n identity assert here: the mass-point control mask uses a + # tolerance while treated is strict, so the sets can overlap in a + # ~1-ULP band; n_obs is the single authoritative count. + self._assert_simple_relay(res) + + def test_simple_view_clustered(self, had_panel_2p): + res = _fit_had(had_panel_2p, est_kw={"cluster": "grp"}) + agg = self._assert_simple_relay(res) + assert np.isnan(agg.df[0]) + + def test_simple_view_survey_tsl_df_provenance(self, had_panel_2p): + from diff_diff import SurveyDesign + + d = had_panel_2p.copy() + rng = np.random.default_rng(23) + wmap = {u: rng.uniform(0.5, 2.0) for u in d["unit"].unique()} + d["w"] = d["unit"].map(wmap) + d["psu"] = d["unit"] % 9 + res = _fit_had(d, survey_design=SurveyDesign(weights="w", psu="psu")) + agg = self._assert_simple_relay(res) + # The survey path passed resolved.df_survey into safe_inference and + # mirrors it on survey_metadata: the relay is provenance-exact. + assert res.survey_metadata is not None + assert float(agg.df[0]) == float(res.survey_metadata.df_survey) + + def test_simple_view_constant_outcome_all_nan_relay(self): + # Degenerate fit contract (had.py class docstring): constant outcome + # on the continuous paths returns (att=nan, se=nan) and the + # safe_inference gate NaNs the downstream triple. The relay carries + # the NaN quintet honestly and __post_init__ NaNs the df column + # wherever p is non-finite. + res = _fit_had(_had_panel_2p(constant_outcome=True)) + agg = self._assert_simple_relay(res) + assert np.isnan(agg.att[0]) and np.isnan(agg.se[0]) + assert np.isnan(agg.t_stat[0]) and np.isnan(agg.p_value[0]) + assert np.isnan(agg.df[0]) + + def test_simple_view_single_cluster_finite_att_nan_inference(self): + # Mass-point single-cluster: att stays finite (the Wald-IV ratio is + # well defined) while the CR1 SE is NaN, so the downstream triple is + # NaN via the safe_inference gate - the relay is bit-exact on that + # mixed state too. + d = _had_panel_2p(mass_point=True).copy() + d["one"] = 0 + res = _fit_had(d, est_kw={"cluster": "one"}) + assert np.isfinite(res.att) and np.isnan(res.se) + agg = self._assert_simple_relay(res) + assert np.isfinite(agg.att[0]) and np.isnan(agg.se[0]) + assert np.isnan(agg.t_stat[0]) and np.isnan(agg.p_value[0]) + assert np.isnan(agg.df[0]) + + def test_simple_summary_estimand_heading(self, had_fitted_2p): + # The M-027 heading widening: a single non-'att' target renders the + # target column + neutral 'estimate' heading (never the hard-coded + # ATT heading, which would mislabel a WAS). + s = had_fitted_2p.aggregate("simple").summary() + assert "estimate" in s + assert had_fitted_2p.target_parameter in s + assert not any("ATT" in line and "label" in line for line in s.splitlines()) + + def test_dcdh_simple_summary_estimand_heading(self, dcdh_fitted): + # The widening also fixes dCDH's shipped mislabel: its estimand- + # labelled single-row relay previously rendered under 'ATT'. + s = dcdh_fitted.aggregate("simple").summary() + assert "estimate" in s + assert "DID_M" in s + assert not any("ATT" in line and "label" in line for line in s.splitlines()) + + def test_event_study_view_matches_builder(self, had_fitted_multi): + from diff_diff.results_base import build_event_study_surface + + es = had_fitted_multi.aggregate("event_study") + assert isinstance(es, EventStudyResults) + assert es.n_kind == "units" # the corrected _from_had kind + built = build_event_study_surface(had_fitted_multi) + a, b = es.to_dataframe(), built.to_dataframe() + assert list(a.columns) == list(b.columns) + assert a.shape == b.shape + for col in a.columns: + av, bv = a[col].to_numpy(), b[col].to_numpy() + if av.dtype.kind in "fc": + np.testing.assert_allclose( + av.astype(float), bv.astype(float), rtol=0, atol=0, equal_nan=True + ) + else: + assert list(av) == list(bv) + + def test_event_study_view_estimand_labels(self, had_fitted_multi, had_panel_multi): + # CI review R1 P1: the container must carry the WAS estimand label - + # summary() previously rendered HAD's numbers under a hardcoded ATT + # heading with no estimand metadata anywhere on the surface. + es = had_fitted_multi.aggregate("event_study") + assert es.estimand == had_fitted_multi.target_parameter + assert es.estimand in ("WAS", "WAS_d_lower") + s = es.summary() + heading = next(line for line in s.splitlines() if "Event time" in line) + assert es.estimand in heading and "ATT" not in heading + assert f"estimand: {es.estimand}" in s + assert es.to_dict()["estimand"] == es.estimand + # The detached frame carries the discriminator too (CI review R2): + # a bare att column would be indistinguishable from an ATT. + frame = es.to_dataframe() + assert list(frame["estimand"].unique()) == [es.estimand] + + def test_event_study_view_estimand_was_at_zero(self): + # The continuous_at_zero design labels the estimand "WAS" (vs the + # near-d_lower fixture's "WAS_d_lower") - both must relay. + rng = np.random.default_rng(31) + n = 150 + d = rng.uniform(0.0, 2.0, n) + d[0] = 0.0 + rows = [] + for i in range(n): + ft = 0 if d[i] == 0.0 else 2 + for t in range(5): + di = d[i] if t >= 2 else 0.0 + rows.append((i, t, di, 1.5 * di + rng.normal(), ft)) + pm = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "ft"]) + res = _fit_had(pm, first_treat="ft") + assert res.target_parameter == "WAS" + es = res.aggregate("event_study") + assert es.estimand == "WAS" + heading = next(line for line in es.summary().splitlines() if "Event time" in line) + assert " WAS " in heading and "ATT" not in heading + + def test_event_study_view_non_had_estimand_none_att_stable(self, fitted): + # Byte-stability: every other producer's estimand stays None and the + # ATT heading is unchanged (no estimand metadata line). + es = fitted.aggregate("event_study") + assert es.estimand is None + s = es.summary() + heading = next(line for line in s.splitlines() if "Event time" in line) + assert " ATT " in heading + assert "estimand:" not in s + assert list(es.to_dataframe()["estimand"].unique()) == ["att"] + + def test_event_study_view_cband_relays(self, had_panel_multi): + # cluster= fires the clustered sup-t band even on an unweighted fit; + # the view must carry the cband fields through _from_had. + d = had_panel_multi.copy() + d["grp"] = d["unit"] % 6 + res = _fit_had( + d, first_treat="ft", est_kw={"cluster": "grp", "n_bootstrap": 199, "seed": 5} + ) + assert res.cband_low is not None + es = res.aggregate("event_study") + np.testing.assert_array_equal(es.cband_lower, np.asarray(res.cband_low)) + np.testing.assert_array_equal(es.cband_upper, np.asarray(res.cband_high)) + assert es.cband_crit_value == res.cband_crit_value + + def test_cross_level_fail_closed_overall_class(self, had_fitted_2p): + for bad in ("event_study", "group", "calendar", "dose", "all"): + with pytest.raises(ValueError, match="Unsupported aggregation type"): + had_fitted_2p.aggregate(bad) + + def test_cross_level_fail_closed_es_class(self, had_fitted_multi): + for bad in ("simple", "group", "calendar", "all"): + with pytest.raises(ValueError, match="Unsupported aggregation type"): + had_fitted_multi.aggregate(bad) + + def test_balance_e_and_weights_rejected(self, had_fitted_2p, had_fitted_multi): + with pytest.raises(ValueError, match="balance_e"): + had_fitted_2p.aggregate("simple", balance_e=1) + with pytest.raises(ValueError, match="balance_e"): + had_fitted_multi.aggregate("event_study", balance_e=1) + with pytest.raises(ValueError, match="weights"): + had_fitted_2p.aggregate("simple", weights="cell") + + def test_idempotent_and_isolated(self, had_fitted_2p, had_fitted_multi): + a1 = had_fitted_2p.aggregate("simple") + a2 = had_fitted_2p.aggregate("simple") + np.testing.assert_array_equal(a1.att, a2.att) + e1 = had_fitted_multi.aggregate("event_study").to_dataframe() + e2 = had_fitted_multi.aggregate("event_study").to_dataframe() + assert e1.equals(e2) + + def test_pickle_round_trip_no_kit(self, had_fitted_2p, had_fitted_multi): + # PURE VIEWS: no kit is attached, so results unpickled from ANY + # release aggregate identically - there is no no-kit error path. + import pickle + + assert not hasattr(had_fitted_2p, "_aggregation_kit") + rt2 = pickle.loads(pickle.dumps(had_fitted_2p)) + np.testing.assert_array_equal( + rt2.aggregate("simple").att, had_fitted_2p.aggregate("simple").att + ) + rtm = pickle.loads(pickle.dumps(had_fitted_multi)) + assert ( + rtm.aggregate("event_study") + .to_dataframe() + .equals(had_fitted_multi.aggregate("event_study").to_dataframe()) + ) + + def test_no_future_warning_from_post_fit_routes(self, had_fitted_2p, had_fitted_multi): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + had_fitted_2p.aggregate("simple") + had_fitted_multi.aggregate("event_study") + assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] diff --git a/tests/test_bootstrap_utils.py b/tests/test_bootstrap_utils.py index bafa5a99..6c12280f 100644 --- a/tests/test_bootstrap_utils.py +++ b/tests/test_bootstrap_utils.py @@ -501,7 +501,7 @@ def test_fpc_baked_in_helper_is_fpc_agnostic(self): np.testing.assert_array_equal(tensor_a, tensor_b) def test_bit_parity_vs_pre_refactor_inline_block(self): - """Regression for the HAD sup-t refactor at ``had.py:2151-2204``. + """Regression for the HAD sup-t refactor at ``had.py:2264-2317``. Locks ``apply_stratum_centering(psu_axis=0)`` bit-exactly against the pre-refactor inline implementation on a 200-row × 4-horizon fixture spanning balanced + unbalanced + singleton strata under diff --git a/tests/test_did_had_parity.py b/tests/test_did_had_parity.py index f3a1d15e..caeb216d 100644 --- a/tests/test_did_had_parity.py +++ b/tests/test_did_had_parity.py @@ -143,7 +143,6 @@ def _python_fit( dose="d", time="t", unit="g", - aggregate="overall", ) return est.fit( panel, @@ -151,7 +150,6 @@ def _python_fit( dose="d", time="t", unit="g", - aggregate="event_study", trends_lin=trends_lin, ) diff --git a/tests/test_event_study_consumers.py b/tests/test_event_study_consumers.py index 7897a170..4975cc32 100644 --- a/tests/test_event_study_consumers.py +++ b/tests/test_event_study_consumers.py @@ -2047,3 +2047,52 @@ def test_real_continuous_container_rejected_by_design(self): compute_honest_did(surface, M=1.0) with pytest.raises(TypeError, match="no reference-period[\\s\\n ]*normalization"): compute_pretrends_power(surface, M=1.0) + + +class TestHADContainerRejection: + """A REAL HAD post-fit container is rejected - DEFERRED, not by-design. + + Two independent grounds, both named in the clause with the corrected + wording: no joint cross-horizon covariance (per-horizon independent + sandwiches; the DEFERRED.md row), and the anchor ROW e = -1 is omitted + from the container - a metadata gap, NOT missing normalization (the + coefficients ARE reference-normalized against the F-1 anchor). The + match strings pin the clause text, not the class name (the + got source={...!r} interpolation would match the class name even + without the message edit). + """ + + def test_real_had_container_rejected_deferred(self): + import warnings + + import numpy as np + import pandas as pd + + from diff_diff import HeterogeneousAdoptionDiD + + rng = np.random.default_rng(29) + rows = [] + for i in range(150): + dose = rng.uniform(0.1, 2.0) + for t in range(5): + d_it = dose if t >= 2 else 0.0 + rows.append((i, t, d_it, 1.2 * d_it + rng.normal(), 2)) + panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "ft"]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = HeterogeneousAdoptionDiD().fit( + panel, + outcome="outcome", + dose="dose", + time="period", + unit="unit", + first_treat="ft", + ) + surface = res.aggregate("event_study") + assert surface.source == "HeterogeneousAdoptionDiDEventStudyResults" + assert surface.vcov is None # per-horizon independent sandwiches only + assert not surface.is_reference.any() # the anchor row is omitted + with pytest.raises(TypeError, match="coefficients ARE reference-normalized"): + compute_honest_did(surface, M=1.0) + with pytest.raises(TypeError, match="coefficients ARE reference-normalized"): + compute_pretrends_power(surface, M=1.0) diff --git a/tests/test_event_study_surface.py b/tests/test_event_study_surface.py index cb8f5ce5..05530d94 100644 --- a/tests/test_event_study_surface.py +++ b/tests/test_event_study_surface.py @@ -1159,7 +1159,7 @@ def _had_event_study(): with warnings.catch_warnings(): warnings.simplefilter("ignore") es = HeterogeneousAdoptionDiD(design="mass_point").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ) return panel, es diff --git a/tests/test_guides.py b/tests/test_guides.py index 1522cc35..a2ba3694 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -177,7 +177,7 @@ def test_autonomous_negative_dose_path_does_not_route_to_had(): `HeterogeneousAdoptionDiD` as a direct routing alternative when `dose_min < 0`. HAD's contract requires non-negative dose support and raises on negative post-period dose - (`had.py:1450-1459`, paper Section 2). Routing to HAD on a + (`had.py:1563-1572`, paper Section 2). Routing to HAD on a negative-dose panel without re-encoding would steer the agent into an unsupported estimator path. Guards against the wording regressing back to a too-broad "HAD as fallback" framing on @@ -196,8 +196,8 @@ def test_autonomous_negative_dose_path_does_not_route_to_had(): "§5.2 must mention HAD by name on the negative-dose branch " "so its non-applicability can be explicitly called out." ) - assert "had.py:1450-1459" in sec_5_2, ( - "§5.2 must cite `had.py:1450-1459` on the negative-dose " + assert "had.py:1563-1572" in sec_5_2, ( + "§5.2 must cite `had.py:1563-1572` on the negative-dose " "branch to anchor HAD's non-negative-dose contract (HAD " "raises on negative post-period dose, paper Section 2). " "Without this citation, the agent could route a " @@ -463,7 +463,7 @@ def test_llms_full_had_results_class_field_lists_match_real_dataclass(self): ) def test_llms_full_had_section_documents_mass_point_survey_vcov_requirement(self): - # Per had.py:3495-3507 the mass-point design rejects the default + # Per had.py:3646-3658 the mass-point design rejects the default # classical vcov family on the survey_design= path # (NotImplementedError). The HAD section must surface this # requirement so an agent reading llms-full.txt and writing a @@ -482,7 +482,7 @@ def test_llms_full_had_section_documents_mass_point_survey_vcov_requirement(self "HAD section must document the mass-point + survey vcov " "requirement: passing vcov_type='hc1' (or robust=True) is " "required on design='mass_point' under survey_design= " - "(per had.py:3495-3507). Without this caveat the documented " + "(per had.py:3646-3658). Without this caveat the documented " "weighted fit example can raise NotImplementedError." ) # 3.7.0: the mass-point guidance must not reference the removed @@ -532,7 +532,7 @@ def test_llms_full_had_variance_formula_describes_all_designs(self): assert "mass_point" in line or "Wald-IV" in line or "mass-point" in line, ( f"effective_dose_mean row must mention mass-point " f"semantics - weighted mass-point fits populate the " - f"weighted Wald-IV dose gap per had.py:3642-3660. " + f"weighted Wald-IV dose gap per had.py:3793-3811. " f"Line: {line!r}" ) break @@ -584,7 +584,7 @@ def test_llms_full_had_event_study_mirrors_weighted_metadata_semantics(self): assert "mass_point" in line or "Wald-IV" in line or "mass-point" in line, ( f"event-study effective_dose_mean row must mention " f"mass-point Wald-IV semantics (event-study path " - f"populates the same denominator per had.py:721-734). " + f"populates the same denominator per had.py:804-817). " f"Line: {line!r}" ) break @@ -927,3 +927,35 @@ def test_llms_full_two_stage_fit_aggregate_line_documents_shim(self): 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_examples_do_not_teach_mode_kwarg(self): + # M-027/M-139 regression guard (local review R1 P2): outside the + # deprecated-signature line itself, the HAD estimator and pretest + # sections must not teach an explicit aggregate= mode value - the + # mode is panel-inferred and every example shows a plain call. + text = get_llm_guide("full") + for header in ("### HeterogeneousAdoptionDiD", "## HAD Pretests"): + start = text.index(header) + ends = [ + p + for p in (text.find("\n### ", start + 1), text.find("\n## ", start + 1)) + if p != -1 + ] + section = text[start : min(ends) if ends else len(text)] + offending = [ + line + for line in section.splitlines() + if ( + "aggregate='overall'" in line + or 'aggregate="overall"' in line + 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 4a8b8c77..045fa894 100644 --- a/tests/test_had.py +++ b/tests/test_had.py @@ -619,29 +619,39 @@ 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_without_first_treat_raises(self): + 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. 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.raises(ValueError, match=r"two time periods|Phase 2b"): - est.fit(panel3, "outcome", "dose", "period", "unit") - - def test_three_periods_with_first_treat_raises(self): + 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 est = HeterogeneousAdoptionDiD() - with pytest.raises(ValueError, match=r"two time periods|Phase 2b"): - est.fit( - panel3, - "outcome", - "dose", - "period", - "unit", - first_treat="ft", - ) + 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", + ) def test_single_period_raises(self): d, _ = _dgp_continuous_at_zero(200, seed=0) @@ -946,34 +956,37 @@ 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). A T=2 panel should - raise a helpful ValueError pointing to ``aggregate='overall'``.""" + """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.""" d, dy = _dgp_continuous_at_zero(200, seed=0) panel = _make_panel(d, dy) est = HeterogeneousAdoptionDiD() - with pytest.raises(ValueError, match="more than two"): - est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - ) + 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.raises(ValueError, match="Invalid aggregate"): - est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="garbage", - ) + with pytest.warns(FutureWarning, match="aggregate"): + with pytest.raises(ValueError, match="Invalid aggregate"): + est.fit( + panel, + "outcome", + "dose", + "period", + "unit", + aggregate="garbage", + ) def test_survey_design_bad_type_raises(self): """survey_design= must be a SurveyDesign-like object with a @@ -2283,9 +2296,7 @@ def test_continuous_at_zero_smoke(self): panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) result = cast( HeterogeneousAdoptionDiDEventStudyResults, - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ), + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit"), ) assert isinstance(result, HeterogeneousAdoptionDiDEventStudyResults) assert result.design == "continuous_at_zero" @@ -2308,7 +2319,7 @@ def test_continuous_near_d_lower_smoke(self): result = cast( HeterogeneousAdoptionDiDEventStudyResults, HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ), ) assert result.design == "continuous_near_d_lower" @@ -2327,7 +2338,7 @@ def test_mass_point_smoke(self): result = cast( HeterogeneousAdoptionDiDEventStudyResults, HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ), ) assert result.design == "mass_point" @@ -2350,9 +2361,7 @@ def test_anchor_not_in_event_times(self): panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) result = cast( HeterogeneousAdoptionDiDEventStudyResults, - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ), + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit"), ) assert -1 not in result.event_times.tolist() @@ -2364,9 +2373,7 @@ def test_post_horizons_start_at_zero(self): panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) result = cast( HeterogeneousAdoptionDiDEventStudyResults, - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ), + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit"), ) # F=3, n_periods=5 -> periods 1..5, F-1=2 is anchor. # e = t-F for t in {1,2,3,4,5} -> {-2,-1,0,1,2}; -1 skipped. @@ -2384,9 +2391,7 @@ def test_design_is_scalar(self): panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) result = cast( HeterogeneousAdoptionDiDEventStudyResults, - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ), + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit"), ) assert isinstance(result.design, str) assert isinstance(result.d_lower, float) @@ -2419,7 +2424,6 @@ def test_staggered_filter_warning(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", ) filter_warnings = [msg for msg in w if "Staggered" in str(msg.message)] assert len(filter_warnings) == 1 @@ -2435,7 +2439,6 @@ def test_staggered_filter_info_populated(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", ) assert result.filter_info is not None assert result.filter_info["F_last"] == 5 @@ -2460,7 +2463,6 @@ def test_staggered_filter_keeps_last_cohort_and_never_treated(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", ) # Paper Appendix B.2: staggered HAD applies to last cohort + keeps # never-treated as the "untreated group" comparison. Earlier cohorts @@ -2488,7 +2490,6 @@ def test_staggered_filter_retains_never_treated_units(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", ) # The fit ran successfully with never-treated retained. Verify # directly: the validator returns data_filtered with expected @@ -2516,7 +2517,6 @@ def test_no_filter_on_single_cohort(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", ) filter_warnings = [msg for msg in w if "Staggered" in str(msg.message)] assert len(filter_warnings) == 0 @@ -2541,7 +2541,7 @@ def test_mass_point_per_horizon_matches_phase_2a(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) es_result = HeterogeneousAdoptionDiD(design="mass_point").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ) assert isinstance(es_result, HeterogeneousAdoptionDiDEventStudyResults) # Phase 2a fit on each post-period (F-1, t) two-period subset. @@ -2570,7 +2570,7 @@ def test_continuous_at_zero_per_horizon_matches_phase_2a(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) es_result = HeterogeneousAdoptionDiD(design="continuous_at_zero").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ) assert isinstance(es_result, HeterogeneousAdoptionDiDEventStudyResults) # Skip pre-period horizons since Phase 2a would reject the pre-pre @@ -2607,10 +2607,11 @@ def test_T2_event_study_raises(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.raises(ValueError, match="more than two"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + 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" + ) def test_T_gt_2_overall_raises(self): rng = np.random.default_rng(0) @@ -2618,19 +2619,21 @@ def test_T_gt_2_overall_raises(self): 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.raises(ValueError, match="aggregate='event_study'"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="overall" - ) + 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" + ) def test_invalid_aggregate_raises(self): d = np.linspace(0.0, 1.0, 100) dy = 0.3 * d panel = _make_panel(d, dy) - with pytest.raises(ValueError, match="Invalid aggregate"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="garbage" - ) + with pytest.warns(FutureWarning, match="aggregate"): + with pytest.raises(ValueError, match="Invalid aggregate"): + HeterogeneousAdoptionDiD(design="auto").fit( + panel, "outcome", "dose", "period", "unit", aggregate="garbage" + ) class TestEventStudyPlacebos: @@ -2649,7 +2652,7 @@ def test_no_pre_trend_placebos_near_zero(self): result = cast( HeterogeneousAdoptionDiDEventStudyResults, HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ), ) pre_mask = result.event_times <= -2 @@ -2674,7 +2677,7 @@ def _fit(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) return HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ) def test_to_dataframe_shape(self): @@ -2758,9 +2761,7 @@ def test_rcs_rejected(self): ) panel = pd.DataFrame(rows) with pytest.raises(ValueError, match="Unbalanced panel"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit") def test_non_contiguous_dose_rejected(self): """Pre/post periods interleaved (dose reversal) raises.""" @@ -2781,9 +2782,7 @@ def test_non_contiguous_dose_rejected(self): ) panel = pd.DataFrame(rows) with pytest.raises(ValueError, match="not contiguous"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit") def test_nan_in_outcome_rejected(self): rng = np.random.default_rng(0) @@ -2793,9 +2792,7 @@ def test_nan_in_outcome_rejected(self): panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) panel.loc[0, "outcome"] = np.nan with pytest.raises(ValueError, match="NaN"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit") def test_nan_in_first_treat_col_rejected(self): rng = np.random.default_rng(0) @@ -2812,7 +2809,6 @@ def test_nan_in_first_treat_col_rejected(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", ) def test_no_pre_period_rejected(self): @@ -2833,9 +2829,7 @@ def test_no_pre_period_rejected(self): ) panel = pd.DataFrame(rows) with pytest.raises(ValueError, match="all-zero dose|pre-period"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit") def test_time_varying_post_F_dose_rejected(self): """Within-unit dose variation across post-periods raises. @@ -2870,9 +2864,7 @@ def test_time_varying_post_F_dose_rejected(self): ) panel = pd.DataFrame(rows) with pytest.raises(ValueError, match="constant dose|time-varying"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit") def test_staggered_ordered_categorical_chooses_chronological_last(self): """Staggered filter uses chronological (not lexicographic) last. @@ -2923,7 +2915,6 @@ def test_staggered_ordered_categorical_chooses_chronological_last(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", ) # Chronological last cohort = "q10", not lexicographic last ("q3" @@ -2976,7 +2967,6 @@ def test_first_treat_col_mismatch_with_dose_raises(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", ) def test_unordered_string_time_col_rejected(self): @@ -3008,9 +2998,7 @@ def test_unordered_string_time_col_rejected(self): ) panel = pd.DataFrame(rows) with pytest.raises(ValueError, match="ordered time column|dtype"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit") def test_ordered_categorical_with_unused_levels_accepted(self): """Ordered categorical with extra unused category levels fits. @@ -3046,7 +3034,7 @@ def test_ordered_categorical_with_unused_levels_accepted(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) result = HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ) # F should be post1 (first observed post-period); event_times # should be [-2, 0, 1] (e=-1 for anchor pre2 is skipped). @@ -3084,7 +3072,7 @@ def test_ordered_categorical_time_col_accepted(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) result = HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ) # post1 is F; e=-2 (pre1) and e=0 (post1), e=1 (post2) expected. assert result.F == "post1" @@ -3116,9 +3104,7 @@ def test_staggered_without_first_treat_col_rejected(self): ) panel = pd.DataFrame(rows) with pytest.raises(ValueError, match="Staggered-timing|first_treat_col"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + HeterogeneousAdoptionDiD(design="auto").fit(panel, "outcome", "dose", "period", "unit") class TestEventStudyGuardsPreserved: @@ -3132,7 +3118,7 @@ def test_continuous_at_zero_nonzero_d_lower_raises(self): panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) est = HeterogeneousAdoptionDiD(design="continuous_at_zero", d_lower=0.3) with pytest.raises(ValueError, match="d_lower == 0"): - est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") + est.fit(panel, "outcome", "dose", "period", "unit") def test_mass_point_d_lower_zero_raises(self): rng = np.random.default_rng(0) @@ -3144,7 +3130,7 @@ def test_mass_point_d_lower_zero_raises(self): with pytest.raises(ValueError, match="d_lower > 0"): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") + est.fit(panel, "outcome", "dose", "period", "unit") def test_continuous_near_rejects_mass_point_sample(self): rng = np.random.default_rng(0) @@ -3156,7 +3142,7 @@ def test_continuous_near_rejects_mass_point_sample(self): with pytest.raises(ValueError, match="mass-point sample"): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") + est.fit(panel, "outcome", "dose", "period", "unit") def test_mass_point_rejects_continuous_sample(self): rng = np.random.default_rng(0) @@ -3167,7 +3153,7 @@ def test_mass_point_rejects_continuous_sample(self): with pytest.raises(ValueError, match="modal mass"): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") + est.fit(panel, "outcome", "dose", "period", "unit") class TestEventStudyNaNPropagation: @@ -3186,7 +3172,7 @@ def test_constant_y_nan_inference(self): result = cast( HeterogeneousAdoptionDiDEventStudyResults, HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ), ) # All per-horizon inference triples should be NaN when fit is degenerate. @@ -3206,7 +3192,7 @@ def test_fit_does_not_mutate_design(self): d[0] = 0.0 panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) est = HeterogeneousAdoptionDiD(design="auto") - est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") + est.fit(panel, "outcome", "dose", "period", "unit") assert est.design == "auto" # raw preserved def test_fit_is_idempotent(self): @@ -3218,8 +3204,8 @@ def test_fit_is_idempotent(self): est = HeterogeneousAdoptionDiD(design="auto") with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - r1 = est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") - r2 = est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") + r1 = est.fit(panel, "outcome", "dose", "period", "unit") + r2 = est.fit(panel, "outcome", "dose", "period", "unit") np.testing.assert_allclose(r1.att, r2.att, atol=1e-14, rtol=0.0) np.testing.assert_allclose(r1.se, r2.se, atol=1e-14, rtol=0.0) @@ -3236,10 +3222,8 @@ def test_sklearn_clone_round_trip(self): assert cloned.alpha == 0.1 with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - r_orig = est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") - r_clone = cloned.fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + r_orig = est.fit(panel, "outcome", "dose", "period", "unit") + r_clone = cloned.fit(panel, "outcome", "dose", "period", "unit") np.testing.assert_allclose(r_orig.att, r_clone.att, atol=1e-14, rtol=0.0) @@ -3258,7 +3242,7 @@ def test_vcov_type_ignored_on_continuous(self): est = HeterogeneousAdoptionDiD(design="auto", vcov_type="classical") with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") + est.fit(panel, "outcome", "dose", "period", "unit") vcov_warnings = [ msg for msg in w if "vcov_type" in str(msg.message) and "ignored" in str(msg.message) ] @@ -3274,7 +3258,7 @@ def test_cluster_threaded_on_continuous_event_study(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") r_cl = HeterogeneousAdoptionDiD(design="auto", cluster="state").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ) cluster_warnings = [ msg @@ -3283,7 +3267,7 @@ def test_cluster_threaded_on_continuous_event_study(self): ] assert len(cluster_warnings) == 0 r_un = HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" + panel, "outcome", "dose", "period", "unit" ) assert not np.allclose(r_cl.se, r_un.se) assert r_cl.vcov_type == "cr1" @@ -3590,7 +3574,6 @@ def test_event_study_weights_smoke(self): "dose", "period", "unit", - aggregate="event_study", ) from diff_diff.survey import SurveyDesign @@ -3602,7 +3585,6 @@ def test_event_study_weights_smoke(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=False, # skip bootstrap ) @@ -4860,7 +4842,7 @@ def test_continuous_clustered_band_end_to_end(self): panel = self._clustered_panel(seed=2) r = HeterogeneousAdoptionDiD( design="continuous_at_zero", cluster="state", n_bootstrap=1500, seed=11 - ).fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study", cband=True) + ).fit(panel, "outcome", "dose", "period", "unit", cband=True) assert r.vcov_type == "cr1" and r.cluster_name == "state" assert r.cband_low is not None and np.all(np.isfinite(r.cband_low)) assert r.cband_high is not None and np.all(np.isfinite(r.cband_high)) @@ -4882,7 +4864,7 @@ def test_unweighted_masspoint_clustered_band_end_to_end(self): panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=7, extra_cols={"state": state}) r = HeterogeneousAdoptionDiD( design="mass_point", cluster="state", d_lower=d_lower, n_bootstrap=1500, seed=17 - ).fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study", cband=True) + ).fit(panel, "outcome", "dose", "period", "unit", cband=True) assert r.vcov_type == "cr1" and r.cluster_name == "state" assert r.cband_low is not None and np.all(np.isfinite(r.cband_low)) assert r.cband_method == "cluster_multiplier_bootstrap" @@ -4899,7 +4881,6 @@ def test_cluster_survey_event_study_raises(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), ) @@ -4918,7 +4899,7 @@ def test_single_cluster_band_nan_and_warns(self): warnings.simplefilter("always") r = HeterogeneousAdoptionDiD( design="continuous_at_zero", cluster="state", n_bootstrap=500, seed=1 - ).fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study", cband=True) + ).fit(panel, "outcome", "dose", "period", "unit", cband=True) assert r.cband_low is None and r.cband_high is None assert any("single cluster" in str(x.message).lower() for x in w) # "Undefined band" (crit=NaN, method/count populated), NOT "band @@ -4930,7 +4911,7 @@ def test_single_cluster_band_nan_and_warns(self): def test_clustered_band_determinism(self): panel = self._clustered_panel(seed=2) kw = dict(design="continuous_at_zero", cluster="state", n_bootstrap=800, seed=21) - fit_kw = dict(aggregate="event_study", cband=True) + fit_kw = dict(cband=True) r1 = HeterogeneousAdoptionDiD(**kw).fit( panel, "outcome", "dose", "period", "unit", **fit_kw ) @@ -4983,7 +4964,7 @@ def test_unweighted_es_cband_fields_none(self): numerical output preserved).""" panel = self._multi_period_panel(G=200) est = HeterogeneousAdoptionDiD(design="continuous_at_zero", seed=0) - r = est.fit(panel, "outcome", "dose", "period", "unit", aggregate="event_study") + r = est.fit(panel, "outcome", "dose", "period", "unit") assert r.cband_low is None assert r.cband_high is None assert r.cband_crit_value is None @@ -5006,7 +4987,6 @@ def test_weighted_es_cband_false_skips_bootstrap(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=False, ) @@ -5035,7 +5015,6 @@ def test_weighted_es_cband_true_populates_band(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=True, ) @@ -5082,7 +5061,6 @@ def test_event_study_filter_info_stable_across_weight_patterns(self): "dose", "period", "unit", - aggregate="event_study", first_treat="first_treat", ) r_uni = est.fit( @@ -5091,7 +5069,6 @@ def test_event_study_filter_info_stable_across_weight_patterns(self): "dose", "period", "unit", - aggregate="event_study", first_treat="first_treat", survey_design=SurveyDesign(weights="w"), cband=False, @@ -5106,7 +5083,6 @@ def test_event_study_filter_info_stable_across_weight_patterns(self): "dose", "period", "unit", - aggregate="event_study", first_treat="first_treat", survey_design=SurveyDesign(weights="w"), cband=False, @@ -5145,7 +5121,6 @@ def test_event_study_mass_point_weighted_smoke(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), ) assert r.design == "mass_point" @@ -5403,7 +5378,6 @@ def test_mass_point_classical_event_study_with_cband_rejected(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=True, ) @@ -5438,7 +5412,6 @@ def test_event_study_zero_weight_units_excluded_from_n_units(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=False, ) @@ -5507,7 +5480,6 @@ def test_mass_point_default_vcov_event_study_cband_rejected(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=True, ) @@ -5548,7 +5520,6 @@ def test_survey_event_study_continuous_end_to_end(self): "dose", "period", "unit", - aggregate="event_study", survey_design=sd, ) assert r.variance_formula == "survey_binder_tsl" @@ -5604,7 +5575,6 @@ def test_survey_event_study_mass_point_end_to_end(self): "dose", "period", "unit", - aggregate="event_study", survey_design=sd, ) assert r.variance_formula == "survey_binder_tsl_2sls" @@ -5762,7 +5732,6 @@ def test_covariates_raises_event_study(self): "dose", "period", "unit", - aggregate="event_study", covariates=["x"], ) diff --git a/tests/test_had_dual_knob_deprecation.py b/tests/test_had_dual_knob_deprecation.py index 96737855..2f84afeb 100644 --- a/tests/test_had_dual_knob_deprecation.py +++ b/tests/test_had_dual_knob_deprecation.py @@ -504,7 +504,6 @@ def test_fit_rejects_pre_resolved_design_event_study(self, event_study_continuou "d", "time", "unit", - aggregate="event_study", survey_design=make_pweight_design(np.ones(200)), ) @@ -641,7 +640,6 @@ def test_survey_design_kwarg_smoke(self, event_study_continuous_panel): "d", "time", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=True, ) @@ -666,7 +664,6 @@ def test_survey_design_kwarg_smoke(self, event_study_panel): "d", "time", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), n_bootstrap=199, seed=0, diff --git a/tests/test_had_pretests.py b/tests/test_had_pretests.py index 70478f83..71d85908 100644 --- a/tests/test_had_pretests.py +++ b/tests/test_had_pretests.py @@ -2300,13 +2300,13 @@ def test_overall_aggregate_unchanged(self): # Phase 3 step-2 gap string STILL present on the overall path assert "paper step 2 deferred" in report.verdict - def test_overall_aggregate_rejects_multi_period(self): - """Registry/docstring contract: aggregate='overall' is two-period only. + def test_overall_battery_rejects_multi_period_on_supplied_path(self): + """Registry/docstring contract: the overall battery is two-period only. - REGISTRY claims `aggregate="overall"` requires a balanced two-period - panel and that multi-period panels are rejected with a pointer to - `aggregate="event_study"`. This test pins the front-door rejection - so the registry text and the validator stay in lock-step. + 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. """ df = _make_multi_period_panel( G=40, @@ -2314,8 +2314,11 @@ def test_overall_aggregate_rejects_multi_period(self): first_treat_period=1999, seed=313, ) - with pytest.raises(ValueError, match=r"aggregate='event_study'"): - did_had_pretest_workflow(df, "y", "d", "period", "unit", n_bootstrap=199, seed=0) + 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" + ) def test_event_study_linear_dgp_all_pass(self): df = self._linear_panel(seed=101) @@ -2325,7 +2328,6 @@ def test_event_study_linear_dgp_all_pass(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=299, seed=17, ) @@ -2369,7 +2371,6 @@ def dose_fn(rng_, G): "d", "period", "unit", - aggregate="event_study", n_bootstrap=499, seed=29, ) @@ -2392,7 +2393,6 @@ def test_event_study_homogeneity_violation_flagged(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=999, seed=31, ) @@ -2420,7 +2420,6 @@ def shifted_dose_fn(rng_, G): "d", "period", "unit", - aggregate="event_study", n_bootstrap=299, seed=42, ) @@ -2430,16 +2429,17 @@ def shifted_dose_fn(rng_, G): def test_invalid_aggregate_raises(self): df = self._linear_panel(seed=102) - with pytest.raises(ValueError, match="aggregate must be one of"): - did_had_pretest_workflow( - df, - "y", - "d", - "period", - "unit", - aggregate="bogus", - n_bootstrap=199, - ) + 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 @@ -2456,7 +2456,6 @@ def test_single_pre_period_yields_pretrends_skipped(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=299, seed=52, ) @@ -2477,7 +2476,6 @@ def test_no_paper_step_2_deferred_string_on_event_study(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=299, seed=61, ) @@ -2509,7 +2507,6 @@ def test_first_treat_col_none_with_staggered_raises(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=199, seed=0, ) @@ -2540,7 +2537,6 @@ def test_staggered_auto_filter_warns(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", n_bootstrap=199, seed=0, ) @@ -2650,7 +2646,6 @@ def test_event_study_small_panel_after_filter_inconclusive_not_crash(self): "period", "unit", first_treat="first_treat", - aggregate="event_study", n_bootstrap=199, seed=0, ) @@ -2775,7 +2770,6 @@ def test_workflow_event_study_ordered_categorical(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=199, seed=13, ) @@ -2823,7 +2817,6 @@ def test_to_dict_event_study_emits_joint_keys(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=199, seed=0, ) @@ -2858,7 +2851,6 @@ def test_to_dataframe_stable_3_row_shape(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=199, seed=0, ) @@ -2881,7 +2873,6 @@ def test_summary_includes_aggregate_header(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=199, seed=0, ) @@ -2901,7 +2892,6 @@ def test_repr_includes_aggregate(self): "d", "period", "unit", - aggregate="event_study", n_bootstrap=199, seed=0, ) @@ -3904,7 +3894,6 @@ def test_workflow_staggered_event_study_weights_subset_correctly(self): "time", "unit", first_treat="F", - aggregate="event_study", survey_design=SurveyDesign(weights="_wcol"), n_bootstrap=199, seed=0, @@ -4377,7 +4366,6 @@ def test_workflow_event_study_psu_only_survey_smoke(self): "d", "time", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w", psu="psu"), n_bootstrap=199, seed=0, @@ -4410,7 +4398,6 @@ def test_workflow_event_study_zero_weights_on_dropped_cohort(self): "time", "unit", first_treat="F", - aggregate="event_study", survey_design=SurveyDesign(weights="_wcol"), n_bootstrap=199, seed=0, @@ -4496,7 +4483,6 @@ def test_workflow_event_study_survey_pass_does_not_say_inconclusive(self): "d", "time", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w", psu="psu"), n_bootstrap=199, seed=0, @@ -4940,7 +4926,6 @@ def test_workflow_trends_lin_forwards_to_joint_wrappers(self): "d", "time", "unit", - aggregate="event_study", n_bootstrap=99, seed=42, trends_lin=True, @@ -5014,7 +4999,6 @@ def test_workflow_trends_lin_minimal_panel_skips_step2_gracefully(self): "d", "time", "unit", - aggregate="event_study", n_bootstrap=99, seed=42, trends_lin=True, @@ -5232,14 +5216,13 @@ def test_workflow_trends_lin_with_overall_aggregate_raises(self): """trends_lin=True only valid on event_study aggregate.""" df = self._panel(rng_seed=34) df_2p = df[df["time"].isin([3, 4])].copy() - with pytest.raises(NotImplementedError, match="trends_lin=True.*event_study"): + with pytest.raises(NotImplementedError, match="trends_lin=True.*multi-period panel"): did_had_pretest_workflow( df_2p, "y", "d", "time", "unit", - aggregate="overall", n_bootstrap=99, seed=42, trends_lin=True, @@ -5259,7 +5242,6 @@ def test_pretrends_consumed_e_minus_2_dropped_in_HAD_fit(self): dose="d", time="time", unit="unit", - aggregate="event_study", ) est_yes = HeterogeneousAdoptionDiD().fit( df, @@ -5267,7 +5249,6 @@ def test_pretrends_consumed_e_minus_2_dropped_in_HAD_fit(self): dose="d", time="time", unit="unit", - aggregate="event_study", trends_lin=True, ) assert -2 in est_no.event_times.tolist() @@ -5310,7 +5291,6 @@ def test_fit_default_bit_exact_backcompat(self): dose="d", time="time", unit="unit", - aggregate="event_study", ) r2 = HeterogeneousAdoptionDiD().fit( df, @@ -5318,7 +5298,6 @@ def test_fit_default_bit_exact_backcompat(self): dose="d", time="time", unit="unit", - aggregate="event_study", trends_lin=False, ) np.testing.assert_array_equal(r1.event_times, r2.event_times) @@ -5328,14 +5307,13 @@ def test_fit_aggregate_overall_with_trends_lin_raises(self): from diff_diff import HeterogeneousAdoptionDiD df = self._panel(F=2, T=2, rng_seed=23) - with pytest.raises(NotImplementedError, match="aggregate='event_study'"): + with pytest.raises(NotImplementedError, match="requires a multi-period panel"): HeterogeneousAdoptionDiD().fit( df, outcome="y", dose="d", time="time", unit="unit", - aggregate="overall", trends_lin=True, ) @@ -5352,7 +5330,6 @@ def test_fit_F2_with_trends_lin_raises(self): dose="d", time="time", unit="unit", - aggregate="event_study", trends_lin=True, ) @@ -5368,7 +5345,6 @@ def test_fit_with_survey_design_and_trends_lin_raises(self): dose="d", time="time", unit="unit", - aggregate="event_study", trends_lin=True, survey_design=SurveyDesign(weights="w"), ) @@ -5386,7 +5362,6 @@ def test_fit_idempotence_with_trends_lin(self): dose="d", time="time", unit="unit", - aggregate="event_study", trends_lin=True, ) r2 = est.fit( @@ -5395,7 +5370,6 @@ def test_fit_idempotence_with_trends_lin(self): dose="d", time="time", unit="unit", - aggregate="event_study", trends_lin=True, ) np.testing.assert_array_equal(r1.event_times, r2.event_times) @@ -5547,7 +5521,6 @@ def test_workflow_stratified_event_study_end_to_end_smoke(self): unit="unit", first_treat="F", survey_design=sd, - aggregate="event_study", n_bootstrap=199, seed=0, alpha=0.05, diff --git a/tests/test_methodology_had.py b/tests/test_methodology_had.py index c2381a33..b9824451 100644 --- a/tests/test_methodology_had.py +++ b/tests/test_methodology_had.py @@ -1053,7 +1053,6 @@ def test_sup_t_bootstrap_skipped_when_cband_false(self) -> None: dose="dose", time="period", unit="unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=False, ) @@ -1080,7 +1079,6 @@ def test_sup_t_bootstrap_skipped_when_overall_aggregate(self) -> None: dose="dose", time="period", unit="unit", - aggregate="overall", survey_design=SurveyDesign(weights="w"), cband=True, # request cband on overall — should be ignored ) @@ -1122,7 +1120,6 @@ class through (only the last cohort is identified under dose="dose", time="period", unit="unit", - aggregate="event_study", ) def test_first_treat_col_activates_last_cohort_auto_filter(self) -> None: @@ -1168,7 +1165,6 @@ def test_first_treat_col_activates_last_cohort_auto_filter(self) -> None: time="period", unit="unit", first_treat="first_treat", - aggregate="event_study", ) # Should produce a valid event-study result (no raise). assert isinstance(result, HeterogeneousAdoptionDiDEventStudyResults) diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index ffcfcbad..40099782 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -114,6 +114,7 @@ "controls", "cohort", "aggregation", + "aggregate", "robust", "clean_control", "group", @@ -136,6 +137,11 @@ "controls", "robust", "aggregation", + # M-139's old token: `.aggregate` is ALSO the canonical post-fit method + # name library-wide, so only the precise lanes may match it - never a + # bare grep (docs/guides-lane hits on canonical successor vocabulary + # are covered by CONSUMER_ALLOWLIST entries below). + "aggregate", } @@ -498,6 +504,12 @@ def _build_rowed_index(): SURFACE_ALLOWLIST = { **{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" + ), "plot_group_effects[groups]": _CS_COHORT + " - cohort selector on the plotting surface", "TripleDifference.fit[group]": ( "rule-3 reserved treated-group 0/1 indicator (v4-design section 8 rule 3)" @@ -897,6 +909,43 @@ def _token_family_code_refs(tok): "rank_control_units' own independent regularization param - not the " "removed SyntheticDiD kwarg (M-001)" ), + # M-139's `aggregate` token doubles as the library's canonical post-fit + # METHOD name, so every doc that teaches `results.aggregate(...)` hits + # 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. + ("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", "docs/methodology/REGISTRY.md"): ( + "the aggregate-postfit register Notes (M-020..M-027) teach the " + "canonical successor method; HAD-section fit-time mode mentions " + "were migrated with M-027" + ), + ("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)" + ), + ("aggregate", "docs/methodology/papers/dechaisemartin-2026-review.md"): ( + "dated phase-closure records describing the API as-shipped at " + "closure time (paper-review docs source from the paper + the " + "then-current implementation; the live surface is the REGISTRY " + "HAD Note)" + ), + ("aggregate", "docs/methodology/papers/roth-2022-review.md"): ( + "canonical post-fit results.aggregate('event_study') pointer " + "(the invalid `aggregate=event` recommendation was corrected " + "with M-027)" + ), + ("aggregate", "docs/methodology/variance-conventions.md"): ( + "canonical post-fit aggregate() vocabulary only (zero fit-time " "kwarg sites)" + ), **{ ( "zeta", diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py index 6541850d..717a1d75 100644 --- a/tests/test_practitioner.py +++ b/tests/test_practitioner.py @@ -722,7 +722,10 @@ def test_had_event_study_pretest_workflow_referenced(self, mock_had_event_study_ output = practitioner_next_steps(mock_had_event_study_results, verbose=False) all_code = " ".join(s.get("code", "") for s in output["next_steps"]) assert "did_had_pretest_workflow" in all_code - assert "aggregate='event_study'" in all_code + # M-027/M-139: the guidance must NOT teach the deprecated mode kwarg + # - the workflow selects the event-study battery from the panel + # shape, and the snippet shows a plain call. + assert "aggregate=" not in all_code def test_had_bandwidth_diagnostics_referenced(self, mock_had_results): output = practitioner_next_steps(mock_had_results, verbose=False) @@ -799,7 +802,7 @@ def test_had_step_4_does_not_misframe_untreated_unit_routing( # Per REGISTRY: HAD is compatible with a small share of # never-treated units (paper edge case), and on staggered # event-study panels never-treated units are explicitly RETAINED - # (Appendix B.2 / had.py:1325). The Step-4 routing must NOT + # (Appendix B.2 / had.py:1432). The Step-4 routing must NOT # carry the wrong "if untreated → not HAD" framing. for fixture in (mock_had_results, mock_had_event_study_results): output = practitioner_next_steps(fixture, verbose=False) @@ -942,11 +945,14 @@ def test_handle_continuous_step_4_honors_had_panel_shape_contract( ) assert had_step is not None, "Step 4 must include a HAD handoff snippet for ContinuousDiD" text = (had_step.get("why", "") + " " + had_step.get("code", "")).lower() - # Snippet or rationale must call out the multi-period event-study path. - assert "aggregate='event_study'" in text or 'aggregate="event_study"' in text, ( - "ContinuousDiD -> HAD handoff must show aggregate='event_study' " - "for multi-period panels; otherwise copy-paste on a multi-period " - "ContinuousDiD result raises at HAD fit time." + # Snippet or rationale must call out the multi-period event-study + # path in the POST-M-027 vocabulary: fit() selects the mode from the + # panel shape, so the handoff must NOT teach the deprecated kwarg + # (copy-paste on a multi-period panel now just works). + assert "event-study" in text or "event_study" in text + assert "aggregate=" not in text, ( + "ContinuousDiD -> HAD handoff must not teach the deprecated " + "fit(aggregate=) kwarg (M-027: the mode is panel-inferred)." ) # And the staggered last-cohort-only caveat must be surfaced. assert "last-cohort" in text or "last cohort" in text, ( @@ -994,7 +1000,7 @@ def test_handle_continuous_step_4_recodes_first_treat_inf_for_had( an explicit `first_treat=inf -> 0` recode in the emitted code snippet. ContinuousDiD silently normalizes `inf` to `0`, but HAD's _validate_had_panel rejects any first_treat value outside - {0, t_post} at the front door (had.py:1096-1102). Without the + {0, t_post} at the front door (had.py:1208-1214). Without the recode, a copy-paste of the advertised handoff on a valid inf-encoded ContinuousDiD panel raises `ValueError: first_treat='first_treat' contains value(s) @@ -1032,7 +1038,7 @@ def test_handle_continuous_step_4_recodes_first_treat_inf_for_had( def test_had_event_study_sup_t_snippet_uses_hc1_for_mass_point_survey_compatibility( self, mock_had_event_study_results ): - # Per had.py:3495-3507 the mass-point design rejects the + # Per had.py:3646-3658 the mass-point design rejects the # default classical vcov family on the survey_design= path # (NotImplementedError). The Step-6 sup-t snippet shows a # generic weighted event-study fit; if it uses the default @@ -1063,7 +1069,7 @@ def test_had_event_study_sup_t_snippet_uses_hc1_for_mass_point_survey_compatibil assert ok, ( "Sup-t / cband snippet must either use vcov_type='hc1' / " "robust=True or surface the mass-point + survey vcov " - "requirement inline. Per had.py:3495-3507 the default " + "requirement inline. Per had.py:3646-3658 the default " "classical sandwich raises NotImplementedError on the " "mass-point + survey path; the example as written would " "fail at fit time on a mass-point panel." @@ -1102,7 +1108,7 @@ def test_had_results_to_dict_docstring_matches_weighted_mass_point_contract(self "HeterogeneousAdoptionDiDResults.to_dict() docstring must " "describe the mass-point effective_dose_mean semantics; " "weighted mass-point fits populate it as the weighted " - "Wald-IV dose gap per had.py:3642-3660." + "Wald-IV dose gap per had.py:3793-3811." ) assert "Wald-IV" in doc or "Z=1" in doc, ( "HeterogeneousAdoptionDiDResults.to_dict() docstring must " @@ -1115,7 +1121,7 @@ def test_had_results_dataclass_docstrings_match_weighted_mass_point_contract(sel # acknowledge that weighted mass-point fits populate # variance_formula in {"pweight_2sls", "survey_binder_tsl_2sls"} # and effective_dose_mean as the weighted Wald-IV dose gap (per - # had.py:3585-3660). PR #402 R5 P3 caught that the dataclass + # had.py:3736-3811). PR #402 R5 P3 caught that the dataclass # field docstrings still said those fields were continuous-only # / None on mass-point - leaving two source-of-truth surfaces # disagreeing about the same public result object. Lock the @@ -1150,7 +1156,7 @@ def test_had_results_dataclass_docstrings_match_weighted_mass_point_contract(sel "HeterogeneousAdoptionDiDResults.effective_dose_mean " "docstring must mention mass-point semantics; weighted " "mass-point fits populate it as the weighted Wald-IV dose " - "gap per had.py:3642-3660." + "gap per had.py:3793-3811." ) assert "Wald-IV" in src or "Z=1" in src, ( "HeterogeneousAdoptionDiDResults.effective_dose_mean " diff --git a/tests/test_profile_panel.py b/tests/test_profile_panel.py index 95ca202d..c3341655 100644 --- a/tests/test_profile_panel.py +++ b/tests/test_profile_panel.py @@ -1239,7 +1239,7 @@ def test_treatment_dose_min_flags_negative_dose_continuous_panels(): treated units (D > 0)"). `ContinuousDiD` as currently implemented does not apply on this panel. `HeterogeneousAdoptionDiD` is also NOT a routing alternative here: HAD requires non-negative - dose support (`had.py:1450-1459`, paper Section 2). The applicable + dose support (`had.py:1563-1572`, paper Section 2). The applicable alternative on the negative-dose branch is linear DiD with the treatment as a signed continuous covariate. Re-encoding the treatment to a non-negative scale is an agent-side preprocessing diff --git a/tests/test_t20_had_brand_campaign_drift.py b/tests/test_t20_had_brand_campaign_drift.py index de18496f..2b8804c4 100644 --- a/tests/test_t20_had_brand_campaign_drift.py +++ b/tests/test_t20_had_brand_campaign_drift.py @@ -116,7 +116,6 @@ def event_study_result(panel): time="week", unit="dma_id", first_treat="first_treat", - aggregate="event_study", ) diff --git a/tests/test_t21_had_pretest_workflow_drift.py b/tests/test_t21_had_pretest_workflow_drift.py index 20b59b57..976d79c8 100644 --- a/tests/test_t21_had_pretest_workflow_drift.py +++ b/tests/test_t21_had_pretest_workflow_drift.py @@ -37,6 +37,7 @@ from __future__ import annotations import warnings +from pathlib import Path import numpy as np import pytest @@ -111,7 +112,6 @@ def overall_report(two_period): alpha=0.05, n_bootstrap=999, seed=WORKFLOW_SEED, - aggregate="overall", ) @@ -127,7 +127,6 @@ def event_study_report(panel): alpha=0.05, n_bootstrap=999, seed=WORKFLOW_SEED, - aggregate="event_study", ) @@ -448,3 +447,29 @@ def test_notebook_quotes_match_pinned_constants(): "7.0076", ] assert_quotes_in_rendered(T21_NOTEBOOK, expected_quotes, surface="rendered") + + +def test_notebook_prose_has_no_malformed_mode_phrases(): + """Regression guard for the M-027/M-139 prose migration (local review + R2 P2): the mechanical kwarg->mode rewording must never leave doubled + phrases like "two-period (the overall (two-period) mode)" or a + pseudo-call like ``did_had_pretest_workflow(the event-study ...)`` in + any HAD tutorial's markdown. Paths anchor at the repo root and the + test skips when docs/ is absent (the isolated-install CI job copies + only tests/ - the ``_tutorial_drift`` loader convention).""" + import json + + root = Path(__file__).resolve().parents[1] + for rel in ( + "docs/tutorials/20_had_brand_campaign.ipynb", + "docs/tutorials/21_had_pretest_workflow.ipynb", + "docs/tutorials/22_had_survey_design.ipynb", + ): + nb = root / rel + if not nb.exists(): + pytest.skip(f"Notebook {rel!r} not available in this CI environment") + cells = json.loads(nb.read_text())["cells"] + text = "\n".join("".join(c["source"]) for c in cells) + assert "did_had_pretest_workflow(the" not in text, rel + assert "the overall (two-period) mode)" not in text, rel + assert "the event-study (multi-period) mode)" not in text, rel diff --git a/tests/test_t22_had_survey_design_drift.py b/tests/test_t22_had_survey_design_drift.py index 78b82d7f..4b98185f 100644 --- a/tests/test_t22_had_survey_design_drift.py +++ b/tests/test_t22_had_survey_design_drift.py @@ -250,7 +250,6 @@ def survey_event_study_result(panel: pd.DataFrame, survey_design: SurveyDesign): time="week", unit="state_id", first_treat="first_treat", - aggregate="event_study", survey_design=survey_design, cband=True, ) @@ -267,7 +266,6 @@ def overall_report(panel_2p: pd.DataFrame, survey_design: SurveyDesign): time="period", unit="state_id", survey_design=survey_design, - aggregate="overall", n_bootstrap=N_BOOTSTRAP, seed=WORKFLOW_SEED, ) @@ -285,7 +283,6 @@ def event_study_report(panel: pd.DataFrame, survey_design: SurveyDesign): unit="state_id", first_treat="first_treat", survey_design=survey_design, - aggregate="event_study", n_bootstrap=N_BOOTSTRAP, seed=WORKFLOW_SEED, ) @@ -665,7 +662,7 @@ def test_overall_and_event_study_verdict_prefixes_distinct(overall_report, event # ============================================================================ # Per CI AI review R1 P1 #1: §3 prose previously claimed "the analytical # local-linear at d_lower does not consume the survey weights". That is -# false — `_fit_continuous` (`diff_diff/had.py:3744-3810`) consumes +# false — `_fit_continuous` (`diff_diff/had.py:3895-3961`) consumes # `weights_arr` in (a) the local-linear `tau_bc` boundary fit, (b) the # numerator `np.average(dy_arr, weights=weights_arr)`, AND (c) the # denominator `np.average(d_reg, weights=weights_arr)`. The two ATTs are diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py index 959aa61e..c8912a0f 100644 --- a/tests/test_v4_matrix.py +++ b/tests/test_v4_matrix.py @@ -124,11 +124,13 @@ # amendments (M-136 LPDiD level value; M-137/M-138 diagnostics time->post) # = 117, plus 2b PR-3a's EfficientDiD balance_e row (M-120, claimed from the # reserved pool) = 118, plus 2b PR-3b's Imputation/TwoStage balance_e rows -# (M-118, M-119, claimed from the reserved pool) = 120. +# (M-118, M-119, claimed from the reserved pool) = 120, plus 2b PR-4's +# HAD workflow-aggregate row (M-139, next free id - the reserved pool is +# spent/earmarked) = 121. # Ids are never reused and terminal rows are never deleted, so the ledger # only grows - raise the floor when rows are added; a lower parse count # means scanner/format drift or an illegal row deletion. -ROW_COUNT_FLOOR = 120 +ROW_COUNT_FLOOR = 121 # Committed snapshot of the shipped id set ("ids are never deleted or reused" # contract - a delete-one-add-one edit keeps the count above the floor but trips @@ -179,6 +181,7 @@ (132, 135), (136, 138), (118, 119), + (139, 139), ] EXPECTED_INITIAL_IDS = frozenset( f"M-{n:03d}" for lo, hi in _INITIAL_ID_RANGES for n in range(lo, hi + 1) @@ -577,14 +580,14 @@ def test_initial_ids_never_deleted(): """The shipped id set is immutable: ids are never deleted or reused (spec section 11). ROW_COUNT_FLOOR alone would let a delete-one-add-one edit pass; this snapshot cannot. - Extends as rows ship (120 as of 2b PR-3b's Imputation/TwoStage balance_e - rows: Phase 1 + diagnostic-family + M-092/M-093 + M-094..M-096 + the + Extends as rows ship (121 as of 2b PR-4's HAD workflow-aggregate row: + Phase 1 + diagnostic-family + M-092/M-093 + M-094..M-096 + the M-097..M-115 public-function completeness sweep + M-117..M-120/M-122 + M-123/M-124 + M-125 + M-126 + M-127..M-131 + M-132..M-135 + - M-136..M-138).""" + M-136..M-138 + M-139).""" missing = sorted(EXPECTED_INITIAL_IDS - set(_ROW_IDS)) assert not missing, f"ledger rows deleted (ids are permanent): {missing}" - assert len(EXPECTED_INITIAL_IDS) == 120 + assert len(EXPECTED_INITIAL_IDS) == 121 def test_version_tuple_pads_to_three_components(): diff --git a/tests/test_v4_rename_mechanical.py b/tests/test_v4_rename_mechanical.py index 688f0f1c..5c48ac1a 100644 --- a/tests/test_v4_rename_mechanical.py +++ b/tests/test_v4_rename_mechanical.py @@ -625,7 +625,6 @@ def test_workflow_rename(self, had_multi_panel): "u", n_bootstrap=99, seed=7, - aggregate="event_study", ) _assert_no_future_warning(record) with pytest.warns( @@ -640,7 +639,6 @@ def test_workflow_rename(self, had_multi_panel): unit_col="u", n_bootstrap=99, seed=7, - aggregate="event_study", ) assert r_new.homogeneity_joint.cvm_stat_joint == r_old.homogeneity_joint.cvm_stat_joint with pytest.raises(ValueError, match=r"pass only time="): @@ -666,9 +664,7 @@ def test_staggered_fail_closed_recommends_first_treat(self, had_multi_panel): rows.append((u, t, dose, 0.3 * t + 0.8 * dose + rng.normal(0, 0.2))) staggered = pd.DataFrame(rows, columns=["u", "t", "dose", "y"]) with pytest.raises(ValueError) as exc: - HeterogeneousAdoptionDiD().fit( - staggered, "y", "dose", "t", "u", aggregate="event_study" - ) + HeterogeneousAdoptionDiD().fit(staggered, "y", "dose", "t", "u") message = str(exc.value) assert "Pass first_treat=" in message assert "first_treat_col" not in message @@ -707,9 +703,7 @@ def test_had_fit_first_treat_col(self, had_multi_panel): df = self._with_ft(had_multi_panel) with warnings.catch_warnings(): warnings.simplefilter("ignore") - r_new = HeterogeneousAdoptionDiD().fit( - df, "y", "dose", "t", "u", first_treat="ft", aggregate="event_study" - ) + r_new = HeterogeneousAdoptionDiD().fit(df, "y", "dose", "t", "u", first_treat="ft") with pytest.warns( FutureWarning, match=r"HeterogeneousAdoptionDiD\.fit\(first_treat_col=\) is deprecated", @@ -721,7 +715,6 @@ def test_had_fit_first_treat_col(self, had_multi_panel): "t", "u", first_treat_col="ft", - aggregate="event_study", ) np.testing.assert_array_equal(r_old.att, r_new.att) with pytest.raises(ValueError, match=r"pass only first_treat="): @@ -733,7 +726,6 @@ def test_had_fit_first_treat_col(self, had_multi_panel): "u", first_treat="ft", first_treat_col="ft", - aggregate="event_study", ) @pytest.mark.parametrize( @@ -741,7 +733,7 @@ def test_had_fit_first_treat_col(self, had_multi_panel): [ ("joint_pretrends_test", {"pre_periods": [0], "base_period": 1}), ("joint_homogeneity_test", {"post_periods": [2, 3], "base_period": 1}), - ("did_had_pretest_workflow", {"aggregate": "event_study"}), + ("did_had_pretest_workflow", {}), ], ) def test_pretest_functions_first_treat_col(self, had_multi_panel, func_name, extra):