From bb4335b1f12ecfad266b5da6fcfc59f579af0127 Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 29 Aug 2026 14:32:02 -0400 Subject: [PATCH 1/2] feat(provenance): per-row ES df threading (M-092 completion) + ContinuousDiD unit-level metadata + df_survey contract - Thread event_study_df into the unified container's per-row df column for EfficientDiD, ImputationDiD, ContinuousDiD, HAD (finite on analytical survey fits; None on non-survey/bootstrapped/no-surface fits and for the replicate-undefined 0 sentinel; ImputationDiD replay carriers use the level-matched replay df). - ContinuousDiD survey_metadata recomputed unit-level on every arm (bootstrap + degenerate no-post-cells arms included). - Document the df_survey scalar two-channel contract (persists beside percentile inference; HonestDiD consumer channel) with a cross-estimator pin; REGISTRY + v4 ledger note prose updated; TODO rows for granularity/scalar/per-row-df closed. --- CHANGELOG.md | 27 ++ TODO.md | 5 +- diff_diff/continuous_did.py | 62 +++-- diff_diff/continuous_did_results.py | 20 +- diff_diff/efficient_did.py | 11 + diff_diff/efficient_did_results.py | 32 ++- diff_diff/guides/llms-full.txt | 1 + diff_diff/had.py | 14 + diff_diff/imputation.py | 12 + diff_diff/imputation_results.py | 31 ++- diff_diff/results_base.py | 3 + .../diff_diff.ContinuousDiDResults.rst | 1 + .../diff_diff.EfficientDiDResults.rst | 1 + ...erogeneousAdoptionDiDEventStudyResults.rst | 1 + .../diff_diff.ImputationDiDResults.rst | 1 + docs/methodology/REGISTRY.md | 16 +- docs/v4-deprecations.yaml | 4 +- tests/test_aggregate_contract.py | 241 ++++++++++++++++++ tests/test_survey_dml.py | 11 + tests/test_survey_metadata_raw_scale.py | 71 +++++- 20 files changed, 506 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3282dccac..0692f6215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Per-row event-study df provenance (M-092 completion)** for the four + remaining holes — `EfficientDiD`, `ImputationDiD`, `ContinuousDiD`, and + `HeterogeneousAdoptionDiD`: each results class gains a results-level + `event_study_df` scalar (appended last; positional `__init__` indexes + unchanged) threaded into the unified event-study container's per-row `df` + column, which was all-NaN even on survey fits whose p-values were governed + by a finite survey df. Finite on analytical survey fits (ImputationDiD: + the final replicate-override df, level-matched on replicate replays, lead + rows included; EfficientDiD: the post-overall snapshot; HAD: the + unit-level design df); `None` — never the replicate-undefined `0` + sentinel — on non-survey fits, on bootstrapped fits (percentile inference + used no df, matching the shipped producer convention), and when no + event-study surface was built. Inference values are unchanged everywhere. +- **`ContinuousDiD` `survey_metadata` granularity unified across inference + branches**: the bootstrap and degenerate no-post-cells arms now publish + the same UNIT-level metadata as the analytical arm (the + CS/EfficientDiD convention); previously they kept the obs-level resolve, + so `sum_weights`/`effective_n`/`n_psu` — and `df_survey` on implicit-PSU + designs — differed from the analytical arm by panel length on the same + data. Metadata provenance only; estimates and inference unchanged. +- **Documented (no behavior change)**: the event-study container's + `df_survey` SCALAR — the fit's resolved scalar inference df — deliberately + persists on bootstrapped fit-time and replayed surfaces (CS, DMLDiD, + EfficientDiD identically) as the consumer channel HonestDiD's container + branches read; the per-row `df` column is the inference-provenance channel + that percentile bootstrap clears. Recorded as a REGISTRY Note with a + cross-estimator parity pin. - **`survey_metadata` raw-scale provenance on the unit-level recompute** (CallawaySantAnna panel + repeated-cross-section lanes, `TripleDifference`/`StaggeredTripleDifference` staggered engine, diff --git a/TODO.md b/TODO.md index 4460dbad6..0fb4b50e0 100644 --- a/TODO.md +++ b/TODO.md @@ -37,12 +37,9 @@ Related tracking surfaces: | `EventStudyResults` inference-provenance fields: the container records no `vcov_type`/`cluster_name`/`n_clusters`/`df_convention`/Conley metadata, so a serialized surface cannot distinguish unit auto-clustering from explicit clustering, survey, Conley, or the one-way carve-out (3(a) R9 review). Adding them is a cross-producer M-092 schema amendment (six builders, to_dict/summary rendering, surface-suite pins) - follow the pre-cut amendment convention (optional fields appended last, ledger note same-diff) rather than bolting onto one producer | `diff_diff/results_base.py` | 3(a) R9 | Mid | Low | | Opt-in singleton-group pruning for TwoWayFixedEffects (static + event-study mode; reghdfe parity): singleton units/periods are currently RETAINED class-wide - the within-demeaned row is zero so points are unchanged, but N/G/residual-df count it and CR1/finite-sample SEs shift (~0.41019 -> 0.40962 measured; REGISTRY "Deviation from R" Note, R5 review) - reghdfe iteratively drops singletons by default while fixest retains them (diff-diff matches fixest); an opt-in knob needs iterative unit+period pruning with consistent cluster/survey/replicate/Conley array subsetting and a default-flip decision protocol (moves published SEs) | `diff_diff/twfe.py`, `diff_diff/estimators.py`, `diff_diff/utils.py` | 3(a) R5 | Mid | Low | | Cohort-timing validation input for the simultaneous-adoption event-study family (TWFE `event_study=True` + MultiPeriodDiD through 3.9): an optional `first_treat=`/`cohort=` column so simultaneous adoption becomes checkable under the contract-valid time-invariant `D_i` indicator - today the staggered-adoption advisory derives timing from within-unit 0->1 transitions, so it can only fire on off-contract time-varying `D_it` input, and with valid `D_i` adoption timing is not observable in the inputs at all (REGISTRY "staggered-adoption detection limit" Notes, both sections); design questions: validate-only vs steering error, and interplay with the M-011 removal | `diff_diff/twfe.py`, `diff_diff/estimators.py` | 3(a) R2 | Mid | Medium | -| `ContinuousDiD` `survey_metadata` granularity diverges across inference branches: the analytical branch recomputes at UNIT level (raw unit-weight sum/range since the raw-scale provenance fix) while `n_bootstrap>0` keeps the OBS-level metadata from `_resolve_survey_for_fit` (raw per-obs sum over the dose-filtered frame) — both raw-scale, but `sum_weights` differs by panel length between branches on the same data (characterization pin: `TestContinuousDiDRawScale::test_bootstrap_metadata_stays_obs_level_raw`). Unify on one granularity (unit-level, matching the analytic branch and CS/EfficientDiD convention) | `diff_diff/continuous_did.py` | survey-meta-raw | Quick | Low | -| Bootstrapped ES REPLAY containers (CS, DMLDiD and EfficientDiD) publish the analytical `survey_metadata.df_survey` provenance scalar beside percentile inference on survey fits — a cross-estimator provenance residual (shipped CS behaves identically; the per-row df/inference channels are correctly NaN, the scalar metadata field is the residual; DMLDiD joined with its 3.11 survey support). Evaluate clearing/gating it on all three estimators together (cross-surface twins) | `diff_diff/staggered_results.py`, `diff_diff/dml_did_results.py`, `diff_diff/efficient_did_results.py`, `diff_diff/results_base.py` | EDiD-replay review | Quick | Low | | DMLDiD replicate-weight survey designs (currently fail closed with `NotImplementedError` pointing here): the IF-reweighting route (`compute_replicate_if_variance` on the augmented scores) is BETTER justified for DMLDiD than for CS — Neyman orthogonality makes the score first-order insensitive to nuisance perturbation — but needs per-cell replicate SE plumbing, the aggregate `_se_from_psi` replicate branch already activates on kit keys, and its own rejection/df test matrix (QR-rank df, `n_valid - 1` tightening) | `diff_diff/dml_did.py` | DML survey PR | 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 | -| 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 | @@ -91,7 +88,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m | Tracking-file contract guard test: reject NEW active deferred-work pointers at `TODO.md` (deferred rows live in `DEFERRED.md`; allowlist for historical/past-tense prose and actionable-row pointers) and assert rows cross-linking a `docs/v4-deprecations.yaml` `M-xxx` id don't restate ledger status. Origin: tracking-split local review R2. | `tests/`, `TODO.md`, `DEFERRED.md` | tracking-split | Quick | Low | | Real-data CI canary for dataset-backed replication tests: `test_methodology_lwdid.py`'s Prop 99 / Walmart goldens skip (visibly) when loaders fall back to synthetic; add a lane or canary asserting `df.attrs["source"] == "lwdid_ssc_ancillary"` in CI so network regressions cannot silently de-gate the replication tests. Follow-on from the loader-fallback repair (#723), which made provenance explicit but deliberately did not add a network-dependent CI lane. | `tests/test_methodology_lwdid.py`, `.github/workflows/` | LWDiD validation suite | Quick | Low | | Route LWDiD into the practitioner code surfaces: `practitioner_next_steps` (`diff_diff/practitioner.py`), `agent_workflow.py`, `DiagnosticReport`, and `BusinessReport` never dispatch to or mention LWDiD — the AI guides now cover it, but the code surfaces need contract decisions (when to recommend it, what diagnostics to run) plus tests | `diff_diff/practitioner.py`, `diff_diff/agent_workflow.py`, `diff_diff/diagnostic_report.py`, `diff_diff/business_report.py` | LWDiD release audit | Mid | Low | -| df-provenance completion follow-up (PR C descoping): a Wooldridge public per-row df channel (a per-key dict that can also carry the hc2_bm BM contrast dofs the scalar `_df_analytic_fallback` cannot represent — the unified surface's Wooldridge df column stays NaN until then); ImputationDiD lead-horizon `event_study_df` (needs the `_compute_lead_coefficients`/`_aggregate_event_study` return-path change plus bootstrap-clearing semantics for post rows); and the `inference_df` (DiD/MPD/SA/StackedDiD) vs `df_inference` (CS/dCDH) adapter-naming unification. NOTE (M-024): StackedDiD's `df_survey=None` on analytical fits is DELIBERATE - the container resolver's `df_inference` fallback not matching `inference_df` is load-bearing (normal-theory FLCI; see the REGISTRY StackedDiD M-024 Note and the pin in `tests/test_event_study_consumers.py`) - the unification must preserve that outcome or renegotiate it explicitly. | `diff_diff/wooldridge_results.py`, `diff_diff/imputation.py`, `diff_diff/results_base.py` | #variance-PR-C | Mid | Low | +| df-provenance completion follow-up (PR C descoping): a Wooldridge public per-row df channel (a per-key dict that can also carry the hc2_bm BM contrast dofs the scalar `_df_analytic_fallback` cannot represent — the unified surface's Wooldridge df column stays NaN until then); ImputationDiD NON-survey per-lead df (the survey-fit scalar `event_study_df` + its bootstrap clearing shipped with the M-092 completion; what remains is surfacing the `resolve_tail_df`-derived per-lead dfs on non-survey fits, which needs the `_compute_lead_coefficients` return-path change); and the `inference_df` (DiD/MPD/SA/StackedDiD) vs `df_inference` (CS/dCDH) adapter-naming unification. NOTE (M-024): StackedDiD's `df_survey=None` on analytical fits is DELIBERATE - the container resolver's `df_inference` fallback not matching `inference_df` is load-bearing (normal-theory FLCI; see the REGISTRY StackedDiD M-024 Note and the pin in `tests/test_event_study_consumers.py`) - the unification must preserve that outcome or renegotiate it explicitly. | `diff_diff/wooldridge_results.py`, `diff_diff/imputation.py`, `diff_diff/results_base.py` | #variance-PR-C | Mid | Low | | `worktree-rm` safety via a tested argv helper: the prose rewrite (ask-before-remove confirmation gate, detached-HEAD reachability/rescue, tip-identity force-delete guard) was reverted to the main version because editing the prose repeatedly reintroduced shell-injection (last: sourcing a state file built from a git-derived branch name). Restore those guards in a `worktree_rm.py` that takes the name via file ingress, invokes git through argv arrays, and has a metacharacter-branch/path injection regression test — the pattern that worked for `pr_prepare.py`/`premerge_scan.py`. | `.claude/commands/worktree-rm.md`, `.claude/scripts/` | skill-audit | Heavy | Medium | | `premerge_scan.py` should scan the staged blob (`git show :path`) for staged methodology files, not the working-tree copy — a stage-then-revert-working-copy edit currently reads the safe working version and misses the staged violation. Union staged-index findings with unstaged/untracked filesystem findings. | `.claude/scripts/premerge_scan.py` | skill-audit | Mid | Low | | Carousel PDF drift guard: the committed deck PDFs are build artifacts of their `generate_*.py` scripts (README rule: regenerate in the same commit), but nothing verifies a committed PDF matches its generator — a source edit without regeneration, or a stale raster chart, passes every claims test. Applies to ALL decks, not just the MMM one. fpdf embeds creation-date metadata, so a byte-compare needs fixed metadata or a per-page rendered-text manifest. Origin: mmm-carousel local review R1. | `carousel/`, `tests/` | mmm-carousel | Mid | Low | diff --git a/diff_diff/continuous_did.py b/diff_diff/continuous_did.py index f1fafd085..b3f9ae516 100644 --- a/diff_diff/continuous_did.py +++ b/diff_diff/continuous_did.py @@ -994,6 +994,33 @@ def fit( _survey_df = None # Set by analytical branch when survey is active + # Recompute survey_metadata from the UNIT-level design on EVERY arm + # (degenerate no-post-cells, bootstrap, analytic) so reported + # sum_weights/effective_n/n_psu/df_survey describe one granularity — + # the CS/EfficientDiD convention. Construction is byte-identical to + # the ones inside _run_bootstrap and _compute_analytical_se. + if resolved_survey is not None and survey_metadata is not None: + from diff_diff.survey import compute_survey_metadata + + _unit_resolved_meta = resolved_survey.subset_to_units_by_row_idx( + precomp["unit_first_panel_row"], + unit_weights=precomp.get("unit_survey_weights"), + ) + # Raw (pre-normalization) unit weights for metadata provenance: + # compute_survey_metadata expects the ORIGINAL scale (resolve() + # rescales pweights to mean 1; scale-invariant fields are + # unaffected either way). ``raw_unit_w_meta`` was snapshotted + # from pristine ``data`` before the df mutations; reindexing to + # ``all_units`` (the dose-filtered unit order) keeps alignment — + # survey weights are unit-constant (validated at resolve time). + assert survey_design is not None + raw_w_unit = ( + raw_unit_w_meta.reindex(precomp["all_units"]).to_numpy(dtype=np.float64) + if raw_unit_w_meta is not None + else np.ones(precomp["n_units"], dtype=np.float64) + ) + survey_metadata = compute_survey_metadata(_unit_resolved_meta, raw_w_unit) + if len(post_gt) == 0: warnings.warn( "No post-treatment (g,t) cells available for aggregation. " @@ -1118,29 +1145,9 @@ def fit( ): _survey_df = 0 - # Recompute survey_metadata from unit-level design so reported - # effective_n/n_psu/df_survey match the inference actually run - _unit_resolved = analytic.get("unit_resolved") - if _unit_resolved is not None: - from diff_diff.survey import compute_survey_metadata - - # Raw (pre-normalization) unit weights for metadata - # provenance: compute_survey_metadata expects the - # ORIGINAL scale (resolve() rescales pweights to mean 1, - # so _unit_resolved.weights would misreport sum_weights/ - # weight_range; scale-invariant fields are unaffected - # either way). ``raw_unit_w_meta`` was snapshotted from - # pristine ``data`` before the df mutations; reindexing - # to ``all_units`` (the dose-filtered unit order that - # built unit_resolved) keeps alignment — survey weights - # are unit-constant (validated at resolve time). - assert survey_design is not None - raw_w_unit = ( - raw_unit_w_meta.reindex(precomp["all_units"]).to_numpy(dtype=np.float64) - if raw_unit_w_meta is not None - else np.ones(precomp["n_units"], dtype=np.float64) - ) - survey_metadata = compute_survey_metadata(_unit_resolved, raw_w_unit) + # (Unit-level survey_metadata is recomputed once for ALL + # arms before the post_gt split; only the replicate-df + # propagation below is analytic-arm-specific.) # Propagate replicate df override to survey_metadata for display # (but not the df=0 sentinel — keep metadata as None for undefined df) @@ -1270,6 +1277,15 @@ def fit( rank_deficient_action=self.rank_deficient_action, event_study_effects=event_study_effects, survey_metadata=survey_metadata, + # Per-row ES df provenance (M-092 completion): the survey df the + # ES rows' safe_inference used. None when no ES surface was + # built, on bootstrap fits (_survey_df stays None there), on + # non-survey fits, and for the replicate-undefined 0 sentinel. + event_study_df=( + float(_survey_df) + if (event_study_effects is not None and _survey_df is not None and _survey_df > 0) + else None + ), ) # Post-fit aggregation kit (row M-025): attached on EVERY fit; # scalars-only on bootstrap fits (the ES route fails closed there). diff --git a/diff_diff/continuous_did_results.py b/diff_diff/continuous_did_results.py index 6d85090f9..a88c4176f 100644 --- a/diff_diff/continuous_did_results.py +++ b/diff_diff/continuous_did_results.py @@ -125,6 +125,11 @@ class ContinuousDiDResults(BaseResults, AggregationMixin): Random seed used for bootstrap. rank_deficient_action : str How rank deficiency is handled (``"warn"``, ``"error"``, ``"silent"``). + event_study_df : float or None + Scalar survey df governing the event-study rows' t-inference. + ``None`` on non-survey fits, on bootstrapped fits, when no + fit-time event-study surface was built, and for the + replicate-undefined ``0`` sentinel. """ dose_response_att: DoseResponseCurve @@ -176,9 +181,16 @@ class ContinuousDiDResults(BaseResults, AggregationMixin): # Survey design metadata (SurveyMetadata instance from diff_diff.survey) survey_metadata: Optional[Any] = field(default=None) # Post-fit aggregation kit (row M-025), attached by ContinuousDiD.fit(). - # Declared LAST for positional-__init__ compatibility. Only the - # 'event_study' recompute reads it; 'simple'/'dose' are views. + # New fields are appended AFTER this one (positional-__init__ + # compatibility). Only the 'event_study' recompute reads it; + # 'simple'/'dose' are views. _aggregation_kit: Optional[Any] = field(default=None, repr=False, compare=False) + # Scalar survey df governing the event-study rows' t-inference. None on + # non-survey fits, on bootstrapped fits (percentile inference; the ES + # recompute also fails closed there), when no event-study surface was + # built, and for the replicate-undefined 0 sentinel. Appended last per + # the positional-__init__ convention above. + event_study_df: Optional[float] = None # Post-fit aggregation routing (M-122 contract). ContinuousDiD's extra # 'dose' level is documented in the ledger row and v4-design section 6; @@ -683,12 +695,16 @@ def _aggregate_compute( # cells exist. meta = bk["survey_metadata"] meta = dataclasses.replace(meta) if meta is not None else None + # Per-row df provenance: the kit's survey df is the value this + # route's safe_inference calls received; 0-sentinel normalized. + _es_df = bk["survey_df"] carrier = dataclasses.replace( self, event_study_effects=es, survey_metadata=meta, alpha=kit.alpha, anticipation=kit.anticipation, + event_study_df=(float(_es_df) if _es_df is not None and _es_df > 0 else None), # _provenance_kwargs reads base_period off the carrier - it # rides the kit like its siblings alpha/anticipation so # post-fit mutation of the public field cannot reach diff --git a/diff_diff/efficient_did.py b/diff_diff/efficient_did.py index 4e05ad692..65bd933e4 100644 --- a/diff_diff/efficient_did.py +++ b/diff_diff/efficient_did.py @@ -1413,6 +1413,16 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any else np.ones(len(data), dtype=np.float64) ) raw_unit_w_meta = raw_obs_w_meta[self._unit_first_panel_row] + # Per-row ES df provenance (M-092 completion): the post-overall + # survey-df snapshot is the df the ES rows' safe_inference used. + # None when no ES surface was built, under bootstrap (percentile + # inference used no df — the shipped producer convention), and for + # the replicate-undefined 0 sentinel (representable only via + # survey_metadata.df_survey). + _es_df_final: Optional[float] = None + if event_study_effects is not None and bootstrap_results is None: + if _survey_df_post_overall is not None and _survey_df_post_overall > 0: + _es_df_final = float(_survey_df_post_overall) self.results_ = EfficientDiDResults( group_time_effects=group_time_effects, overall_att=overall_att, @@ -1432,6 +1442,7 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any bootstrap_weights=self.bootstrap_weights, seed=self.seed, event_study_effects=event_study_effects, + event_study_df=_es_df_final, group_effects=group_effects, efficient_weights=stored_weights if stored_weights else None, omega_condition_numbers=stored_cond if stored_cond else None, diff --git a/diff_diff/efficient_did_results.py b/diff_diff/efficient_did_results.py index 82df5d08f..5553070bd 100644 --- a/diff_diff/efficient_did_results.py +++ b/diff_diff/efficient_did_results.py @@ -209,6 +209,11 @@ class EfficientDiDResults(BaseResults, AggregationMixin): omega_ridge : float Relative ridge used for the Omega* inversion behind the efficient weights (0 = legacy exact-inverse/pseudoinverse path). + event_study_df : float or None + Scalar df governing the event-study rows' t-inference (the fit's + survey df). ``None`` on non-survey fits, on bootstrapped fits + (percentile inference used no df), and when no fit-time + event-study surface was built. """ group_time_effects: Dict[Tuple[Any, Any], Dict[str, Any]] @@ -262,9 +267,15 @@ class EfficientDiDResults(BaseResults, AggregationMixin): omega_ridge: float = 0.0 # Survey design metadata (SurveyMetadata instance from diff_diff.survey) survey_metadata: Optional[Any] = field(default=None) - # Post-fit aggregation kit (M-023) - declared LAST: the generated - # __init__'s positional indexes are public API (CS precedent). + # Post-fit aggregation kit (M-023). The generated __init__'s positional + # indexes are public API (CS precedent), so new fields are appended + # AFTER this one, never before. _aggregation_kit: Optional[Any] = field(default=None, repr=False, compare=False) + # Scalar df governing the event-study rows' t-inference (the fit's + # survey df; None on non-survey fits, on bootstrapped fits — percentile + # inference used no df — and when no event-study surface was built). + # Appended last per the positional-__init__ convention above. + event_study_df: Optional[float] = None # Post-fit aggregate() hooks (M-023). Plain class attributes (no # annotation) so they never enter dataclasses.fields; the mixin's @@ -436,11 +447,10 @@ def _aggregate_compute( n_clusters=bk["n_clusters"], ) if boot_replay is not None: - # Same applier as fit-time. The carrier below needs NO field - # clearing: this class has no vcov/cband/df fields for the ES - # surface (its published df column is all-NaN by construction), - # and the non-None bootstrap_results it retains keeps the - # container's inference provenance honest. + # Same applier as fit-time. The carrier clears only + # event_study_df below (this class has no vcov/cband ES + # fields), and the non-None bootstrap_results it retains keeps + # the container's inference provenance honest. apply_bootstrap_event_study_overrides(es, boot_replay, kit.alpha) # Carrier + shared builder: EDiD is a _from_relative_dict producer, # so the recomputed dict rides the same route as the fit-time @@ -460,6 +470,13 @@ def _aggregate_compute( # mutated public field - a PT-All fit whose public pt_assumption # was flipped to "post" would otherwise mark the genuine e=-1 # estimate as a reference row and zero it. + # Per-row df provenance: the kit's post-overall df snapshot on + # analytical recomputes; cleared on bootstrap replays (percentile + # inference used no df) and for the replicate-undefined 0 sentinel. + _es_df = bk["df_survey"] + es_df_carrier = ( + float(_es_df) if (boot_replay is None and _es_df is not None and _es_df > 0) else None + ) carrier = dataclasses.replace( self, event_study_effects=es, @@ -467,6 +484,7 @@ def _aggregate_compute( pt_assumption=bk["pt_assumption"], anticipation=kit.anticipation, alpha=kit.alpha, + event_study_df=es_df_carrier, ) return build_event_study_surface(carrier) diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 83d401d7e..f2a9a3a82 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -2116,6 +2116,7 @@ Per-horizon event-study results container for `HeterogeneousAdoptionDiD`'s event | `cband_crit_value` | `float | None` | Sup-t critical value used for the simultaneous band | | `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 | +| `event_study_df` | `float | None` | Scalar survey df governing every horizon's t-inference (the unit-level design's `df_survey`); `None` on non-survey fits. Threaded into the unified container's per-row `df` column via `_from_had` | **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) diff --git a/diff_diff/had.py b/diff_diff/had.py index 6e6c5a95d..91bb0d0e2 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -788,6 +788,9 @@ class HeterogeneousAdoptionDiDEventStudyResults(BaseResults, AggregationMixin): retained), ``"n_dropped"`` (units dropped), ``"dropped_cohorts"`` (list of dropped cohort labels). ``None`` when no filter was applied. + event_study_df : float or None + Scalar survey df governing every horizon's t-inference (the + unit-level design's ``df_survey``); ``None`` on non-survey fits. """ # Per-horizon arrays @@ -859,6 +862,12 @@ class HeterogeneousAdoptionDiDEventStudyResults(BaseResults, AggregationMixin): """Number of multiplier-bootstrap replicates used to compute the sup-t critical value. ``None`` when ``cband=False`` or on unweighted, unclustered fits.""" + event_study_df: Optional[float] = None + """Scalar survey df governing every horizon's t-inference (the + unit-level design's ``df_survey``). ``None`` on non-survey fits (normal + inference). Pointwise inference is always analytical (``n_bootstrap`` + feeds only the sup-t band), so no bootstrap clearing applies. Appended + last (positional-``__init__`` compatibility).""" def __repr__(self) -> str: base = ( @@ -1003,6 +1012,7 @@ def to_dict(self) -> Dict[str, Any]: "cband_crit_value": self.cband_crit_value, "cband_method": self.cband_method, "cband_n_bootstrap": self.cband_n_bootstrap, + "event_study_df": self.event_study_df, } def to_dataframe(self) -> pd.DataFrame: @@ -4904,4 +4914,8 @@ def _fit_event_study( cband_crit_value=cband_crit_value, cband_method=cband_method_label, cband_n_bootstrap=cband_n_bootstrap_eff, + # Per-row ES df provenance (M-092 completion): the survey df + # every horizon's safe_inference received; None on non-survey + # fits (df_infer is None there). + event_study_df=(float(df_infer) if df_infer is not None and df_infer > 0 else None), ) diff --git a/diff_diff/imputation.py b/diff_diff/imputation.py index 7e1296e60..7af995732 100644 --- a/diff_diff/imputation.py +++ b/diff_diff/imputation.py @@ -1004,6 +1004,17 @@ def fit( _cluster_name_for_results = unit _n_clusters_for_results = int(data[unit].nunique()) + # Per-row ES df provenance (M-092 completion): the FINAL survey df — + # on replicate fits the level-matched override value that rewrote + # every ES row's inference; the seed value on plain survey fits. + # None when no ES surface was built, under bootstrap (percentile + # inference used no df — the shipped producer convention), on + # non-survey fits, and for the replicate-undefined 0 sentinel. + _es_df_final: Optional[float] = None + if event_study_effects is not None and bootstrap_results is None: + if _survey_df is not None and _survey_df > 0: + _es_df_final = float(_survey_df) + # Construct results self.results_ = ImputationDiDResults( treatment_effects=treated_df, @@ -1031,6 +1042,7 @@ def fit( n_clusters=_n_clusters_for_results, leave_one_out=self.leave_one_out, df_convention=self.df_convention, + event_study_df=_es_df_final, ) # Attach the post-fit aggregation kit (M-021/M-118). Unconditional — diff --git a/diff_diff/imputation_results.py b/diff_diff/imputation_results.py index 7b4f46708..17b931e56 100644 --- a/diff_diff/imputation_results.py +++ b/diff_diff/imputation_results.py @@ -155,6 +155,12 @@ class ImputationDiDResults(BaseResults, AggregationMixin): Populated by pretrend_test(). bootstrap_results : ImputationBootstrapResults, optional Bootstrap inference results. + event_study_df : float or None + Scalar survey df governing the event-study rows' t-inference (the + FINAL replicate-override value on replicate fits; leads included + on survey fits). ``None`` on non-survey fits, on bootstrapped + fits, when no fit-time event-study surface was built, and for the + replicate-undefined ``0`` sentinel. """ treatment_effects: pd.DataFrame @@ -199,9 +205,15 @@ class ImputationDiDResults(BaseResults, AggregationMixin): # Private panel-backed post-fit aggregation kit (rows M-021/M-118), # attached by ImputationDiD.fit(). None on results unpickled from a # pre-3.9 release (aggregate() then fails with the re-fit message). - # Appended LAST (the generated __init__ positional indexes are public - # API). + # New fields are appended AFTER this one (the generated __init__ + # positional indexes are public API). _aggregation_kit: Optional[Any] = field(default=None, repr=False, compare=False) + # Scalar survey df governing the event-study rows' t-inference (leads + # included on survey fits). None on non-survey fits, on bootstrapped + # fits (percentile inference used no df), when no event-study surface + # was built, and for the replicate-undefined 0 sentinel. Appended last + # per the positional-__init__ convention above. + event_study_df: Optional[float] = None # Post-fit aggregation vocabulary (M-021). balance_e keeps the mixin # default ("event_study",) - CS precedent, do not redeclare. @@ -358,11 +370,10 @@ def _aggregate_compute( # Carrier + shared builder: ImputationDiD is a _from_relative_dict # producer, so the recomputed dict rides the same route as the # fit-time surface (zero-count-sentinel reference marking, - # n_kind="obs", all-NaN per-row df - identical to fit-time output). - # The carrier's metadata is a copy-on-use of the KIT's fit-final - # metadata copy (never the mutable public field); on a replicate - # replay its df_survey is the REPLAYED level-matched value, - # normalized by the same rule fit applies. + # n_kind="obs"). The carrier's metadata is a copy-on-use of the + # KIT's fit-final metadata copy (never the mutable public field); + # on a replicate replay its df_survey is the REPLAYED level-matched + # value, normalized by the same rule fit applies. meta = bk["survey_metadata"] if meta is not None: if bk["uses_replicate"]: @@ -371,12 +382,18 @@ def _aggregate_compute( ) else: meta = dataclasses.replace(meta) + # Per-row df provenance: the df this route's ES rows actually used — + # the level-matched replay value on replicate replays (the fit-time + # snapshot came from the [overall]-only stack and can diverge), the + # seed df otherwise; 0-sentinel normalized. + _es_df = replay_df if bk["uses_replicate"] else bk["survey_df_seed"] carrier = dataclasses.replace( self, event_study_effects=es, survey_metadata=meta, anticipation=kit.anticipation, alpha=kit.alpha, + event_study_df=(float(_es_df) if _es_df is not None and _es_df > 0 else None), ) return build_event_study_surface(carrier) diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index 0b0962ef0..182803995 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -1348,6 +1348,9 @@ def _from_had(results: Any) -> EventStudyResults: # 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), + # Per-row df provenance (M-092 completion): HAD's scalar survey df + # governs every horizon's t-inference; None on non-survey fits. + df=getattr(results, "event_study_df", None), **_provenance_kwargs(results), ) diff --git a/docs/api/_autosummary/diff_diff.ContinuousDiDResults.rst b/docs/api/_autosummary/diff_diff.ContinuousDiDResults.rst index 8d496a7d0..de2a56c2a 100644 --- a/docs/api/_autosummary/diff_diff.ContinuousDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.ContinuousDiDResults.rst @@ -37,6 +37,7 @@ ~ContinuousDiDResults.degree ~ContinuousDiDResults.epv_threshold ~ContinuousDiDResults.estimation_method + ~ContinuousDiDResults.event_study_df ~ContinuousDiDResults.event_study_effects ~ContinuousDiDResults.is_significant ~ContinuousDiDResults.n_bootstrap diff --git a/docs/api/_autosummary/diff_diff.EfficientDiDResults.rst b/docs/api/_autosummary/diff_diff.EfficientDiDResults.rst index a74f7f7fd..9583ee34b 100644 --- a/docs/api/_autosummary/diff_diff.EfficientDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.EfficientDiDResults.rst @@ -36,6 +36,7 @@ ~EfficientDiDResults.control_group ~EfficientDiDResults.efficient_weights ~EfficientDiDResults.estimation_path + ~EfficientDiDResults.event_study_df ~EfficientDiDResults.event_study_effects ~EfficientDiDResults.group_effects ~EfficientDiDResults.influence_functions diff --git a/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst b/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst index 7c97ea0e7..9f9e63671 100644 --- a/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst +++ b/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst @@ -32,6 +32,7 @@ ~HeterogeneousAdoptionDiDEventStudyResults.cband_n_bootstrap ~HeterogeneousAdoptionDiDEventStudyResults.effective_dose_mean ~HeterogeneousAdoptionDiDEventStudyResults.variance_formula + ~HeterogeneousAdoptionDiDEventStudyResults.event_study_df ~HeterogeneousAdoptionDiDEventStudyResults.event_times ~HeterogeneousAdoptionDiDEventStudyResults.att ~HeterogeneousAdoptionDiDEventStudyResults.se diff --git a/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst b/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst index 12addb10e..503c13700 100644 --- a/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst @@ -50,6 +50,7 @@ ~ImputationDiDResults.overall_t_stat ~ImputationDiDResults.overall_p_value ~ImputationDiDResults.overall_conf_int + ~ImputationDiDResults.event_study_df ~ImputationDiDResults.event_study_effects ~ImputationDiDResults.group_effects ~ImputationDiDResults.groups diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 20b26ccb0..629d6b997 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -1186,7 +1186,7 @@ The multiplier bootstrap uses random weights w_i with E[w]=0 and Var(w)=1: - **Note:** Repeated cross-sections (`panel=False`, Phase 7b): supports surveys like BRFSS, ACS annual, and CPS monthly where units are not followed over time. Uses cross-sectional DRDID (Sant'Anna & Zhao 2020, Section 4): `reg` matches `DRDID::reg_did_rc` (Eq 2.2), `dr` matches `DRDID::drdid_rc` (locally efficient, Eq 3.3+3.4 with 4 OLS fits), `ipw` matches `DRDID::std_ipw_did_rc`. Per-observation influence functions instead of per-unit. All three estimation methods support covariates and survey weights. - **Note:** Panel and RCS influence functions use the library-wide `phi_i = psi_i / n` convention (SE = `sqrt(sum(phi^2))`, algebraically equivalent to R's `sd(psi)*sqrt(n-1)/n`). Leading IF terms are computed on psi scale and divided by n; PS nuisance corrections are computed on psi scale (`score @ solve(Hessian)`) with a single `/n` conversion to phi. - **Note:** Non-survey DR path also includes nuisance IF corrections (PS + OR), matching the survey path structure (Phase 7a). Previously used plug-in IF only. As of v3.7 the non-survey reg and ipw paths carry their corrections too (OR estimation-effect / PS score), so the nuisance-IF treatment is method-uniform. -- **Note (post-fit aggregate() - rows M-020/M-117):** `fit(aggregate=)` is deprecated (3.9; removed 4.0) in favor of post-fit `results.aggregate(type, balance_e=)` on the fit-retained aggregation kit ('simple' relays the stored overall inference; 'event_study'/'group' recompute from the kit — on BOOTSTRAPPED fits they REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` RNG-state snapshot: percentile se/CI/cband matching a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose`, never bit-identity; the discrete percentile p-value is a count statistic outside that claim), vcov/df cleared (no analytical provenance beside percentile inference), backend-stamped and failing closed on a Rust-vs-NumPy weight-backend mismatch or a pre-replay legacy pickle — the M-027 per-level policy, with the former blanket fail-closed retired). `DiagnosticReport` now derives the event-study container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends`, `pretrends_power`, and `sensitivity` checks run without the deprecated fit-time kwarg — CS containers are M-093-admitted into `compute_pretrends_power` / `HonestDiD.sensitivity_analysis` with pinned raw-route parity, and the raw field, when present (the requested-but-empty `{}` included), always takes precedence. `heterogeneity` is unaffected (it reads `group_time_effects` on plain fits). Bootstrapped fits now DERIVE successfully via the percentile-bootstrap replay: the derived container carries `vcov=None`, so `parallel_trends` runs on the Bonferroni per-period fallback and `pretrends_power`/`sensitivity` ride the diagonal-covariance fallback; the replay's re-emitted fit-time warnings are recorded and republished per section. The fail-closed skip remains for kit-less/legacy pickles, backend-mismatched replays, and the sibling estimators' bootstrap gates. The recompute levels' replay RE-EMITS the fit-time bootstrap warnings by design (the relay levels' no-re-warn convention is scoped to 'simple'/'total'). +- **Note (post-fit aggregate() - rows M-020/M-117):** `fit(aggregate=)` is deprecated (3.9; removed 4.0) in favor of post-fit `results.aggregate(type, balance_e=)` on the fit-retained aggregation kit ('simple' relays the stored overall inference; 'event_study'/'group' recompute from the kit — on BOOTSTRAPPED fits they REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` RNG-state snapshot: percentile se/CI/cband matching a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose`, never bit-identity; the discrete percentile p-value is a count statistic outside that claim), vcov/df cleared (no analytical provenance beside percentile inference). **Note (df_survey two-channel contract):** the container-level `df_survey` SCALAR - the fit's resolved scalar inference df (`EventStudyResults.df_survey`) - DELIBERATELY persists on bootstrapped fit-time and replayed surfaces (CS, DMLDiD, EfficientDiD publish it identically): it is a live consumer channel (HonestDiD's container branches consume it as the `df=` for their critical values on the containers they admit), and only the PER-ROW `df` column is the per-row inference-provenance channel that percentile bootstrap clears. A bootstrapped ImputationDiD survey fit with `pretrends=True` clears the per-row channel even though the lead rows retained analytical survey-df inference (the bootstrap loop skips pre-period horizons) - the shipped producer convention, backend-stamped and failing closed on a Rust-vs-NumPy weight-backend mismatch or a pre-replay legacy pickle — the M-027 per-level policy, with the former blanket fail-closed retired). `DiagnosticReport` now derives the event-study container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends`, `pretrends_power`, and `sensitivity` checks run without the deprecated fit-time kwarg — CS containers are M-093-admitted into `compute_pretrends_power` / `HonestDiD.sensitivity_analysis` with pinned raw-route parity, and the raw field, when present (the requested-but-empty `{}` included), always takes precedence. `heterogeneity` is unaffected (it reads `group_time_effects` on plain fits). Bootstrapped fits now DERIVE successfully via the percentile-bootstrap replay: the derived container carries `vcov=None`, so `parallel_trends` runs on the Bonferroni per-period fallback and `pretrends_power`/`sensitivity` ride the diagonal-covariance fallback; the replay's re-emitted fit-time warnings are recorded and republished per section. The fail-closed skip remains for kit-less/legacy pickles, backend-mismatched replays, and the sibling estimators' bootstrap gates. The recompute levels' replay RE-EMITS the fit-time bootstrap warnings by design (the relay levels' no-re-warn convention is scoped to 'simple'/'total'). - **Note (post-fit `aggregate('total')` - the estimator-owned total incremental outcome, 3.10):** (a) **Estimand:** `total = C x overall_att` with `se = C x overall_se`, CI scaled by `C`, `t`/`p`/`df` inherited unchanged - an exact relay CONDITIONAL ON THE REALIZED AGGREGATION MASS (never call it unqualified "exact"): `C` is the design-fixed complete-case treated-observation count on every admitted routing, the inherited SE prices the overall's full inference (WIF/estimated-share terms included) but treats `C` as fixed at its realized value, and the random-mass `att*dC` variance term is deliberately out of scope (the DEFERRED survey/RC remainder). Every SE branch is homogeneous of degree 1 in the scaling, so the relay is exact under analytical AND percentile-bootstrap inference. (b) **Fail-closed routing gates**, in order: (1) repeated-cross-section-routed fits (`panel=False`, or genuinely-unbalanced `allow_unbalanced_panel=True` fits - a balanced panel with the flag stays panel-routed and admitted) raise `NotImplementedError` - the RC cohort mass counts UNITS and weights every kept post period by it, a ~T-fold overcount of treated observations on a T-wave RCS (execution-verified); (2) fits declaring a `survey_design=` raise - the realized-mass relay omits the survey mass-uncertainty term and design-aware population-scale totals are not implemented (retained weight scale differs by design family: analytic pweight resolves normalized to sum = n and CS retains no PER-OBSERVATION raw-weight record - the `survey_metadata` scalars report raw-scale `sum_weights`/`weight_range` provenance, but not the per-obs raw vector a population-scale total would need; CS accepts pweight only - while replicate designs retain raw scale); the gate reads the fit-time `is_survey_fit` KIT snapshot, so bare-`cluster=` fits are ADMITTED (their synthesized all-ones internal design is not a survey) and a post-fit edit of the public `survey_metadata` cannot bypass it; the DECLARATION BOUNDARY is deliberate - an explicit unweighted `SurveyDesign(psu=...)` fit is numerically identical to bare `cluster=` yet fails closed by declaration (declaring survey_design opts into the survey contract; conservative, never wrong numbers; the message points at the `cluster=` route); a combined survey+RC fit deterministically gets the RC message; (3) the coincidence check: on bare-`cluster=` fits `_aggregate_simple` weights kept cells by the synthesized cohort masses (full cohort size per kept cell), and when kept cells have INCOMPLETE treated support that realized mass diverges from the complete-case count - the same overcount class as (1) - so the fit raises rather than publishing an ambiguous mass; clean bare-`cluster=` fits (the normal case) are admitted with `C` equal to both counts. Panel-routed pre-upgrade (legacy) kits missing the fit-time snapshots raise the refit message - never a mutable-field or public-replay fallback. (c) **Filter-replay contract:** `C` replays `_aggregate_simple`'s cell selection verbatim over the immutable `agg_gt_cells` fit-time kit snapshot (EDiD snapshot discipline; the kit builder stashes `(g, t, effect, n_treated)` 4-tuples in dict order) - anticipation filter FIRST (cells with `g - anticipation <= t < g` COUNT as post, the business-facing subtlety; universal-base reference cells are excluded by this filter alone, their base period being `< g - anticipation` by construction), finite-effect mask SECOND, weight = cohort mass where `fixed_cohort_agg_weights` provides one else per-cell complete-case `n_treated` (the source's `agg_weight` fallback is deliberately absent - RC-only, gated out), pairwise `np.sum`; the overall scalars come from the same stored fields the `'simple'` relay reads, so a post-fit mutation of `group_time_effects` moves NEITHER factor and `total == C x aggregate('simple').att` cannot desynchronize (pinned by a mutation test). NEVER use `agg_total_weight` (the RC all-units WIF mass). (d) **NaN rules:** empty post set / all-NaN effects give `C = NaN` and an all-NaN row WITHOUT re-emitting the fit-time UserWarnings (the RELAY-level no-re-warn convention — the NaN row is the signal; the recompute levels' bootstrap replay re-emits by design); with finite `C` the relay is VERBATIM - inherited NaNs (a degenerate fit's `(nan, nan)` CI beside finite att/se = 0) pass through, mirroring `aggregate('simple')` per the repo's non-estimable-row convention (att/n are never blanked by NaN inference fields); the ONLY additional blanking is true float overflow (a finite overall value made non-finite BY the `x C` scaling - unreachable in practice, guarded per this code family's finiteness convention). (e) **Bootstrap:** 'total' is a RELAY level - available on bootstrapped fits with the df column NaN (the M-027 per-level policy). (f) **Container conventions:** single row, `level="total"`, `label=["total"]`, `target=["total"]` (a total incremental outcome, NOT an ATT - the single-non-att neutral "estimate" rendering applies), `n = [C]` with `n_kind="obs"`, `weight=[1.0]`, per-class df carrier (CS: `resolve_inference_df` - finite on admitted bare-`cluster=` fits, NaN on plain analytical panel fits). Per-level `n` semantics are deliberately DIFFERENT: 'total''s `n` is the treated observations entering the support, while 'simple''s `n` stays treated+control units by contract - `n_kind` is per-container and each is documented, satisfying the never-conflate rule. (g) **MMM admission:** both `diff_diff.mmm` exporters accept the total container ALONE - `scale` (numeric or `"auto"`) is rejected as double-counting; for overall-total exports this route supersedes `scale="auto"` (see the MMM section). The total remains meaningful only for outcomes additive in levels - the same unverifiable caveat every scale route carries. @@ -1562,7 +1562,8 @@ 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 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). (g) **Report-layer consumption:** `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent) for its `heterogeneity` check — the only ES-gated check in ContinuousDiD's applicability; captured recompute warnings are re-published on the section and derivation failures (bootstrap fits) surface as explicit skip context. +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 event-study container's PER-ROW df column now threads the same survey df via the results-level `event_study_df` channel (M-092 completion; finite on analytical survey fits, None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel), beside the scalar `df_survey` channel (the two-channel contract - see the CallawaySantAnna post-fit Note). (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). (g) **Report-layer consumption:** `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent) for its `heterogeneity` check — the only ES-gated check in ContinuousDiD's applicability; captured recompute warnings are re-published on the section and derivation failures (bootstrap fits) surface as explicit skip context. +9. **Note:** `survey_metadata` is recomputed from the UNIT-level resolved design on EVERY inference arm - analytic, bootstrap, and the degenerate no-post-cells arm - matching the CallawaySantAnna/EfficientDiD convention (3.12; previously the bootstrap arm published the obs-level metadata from `_resolve_survey_for_fit`, so `sum_weights`/`effective_n`/`n_psu` - and, on implicit-PSU designs, `df_survey` - diverged from the analytic arm by panel length). Provenance-only: estimates and inference are unaffected (the bootstrap arm's inference remains percentile with no df). ### Implementation Checklist @@ -1772,7 +1773,7 @@ where `q_{g,e} = pi_g / sum_{g' in G_{trt,e}} pi_{g'}`. - **Note:** `vcov_type` is permanently narrow to `{"hc1"}` per the Chen-Sant'Anna-Xie (2025) EIF-based variance achieving the semiparametric efficiency bound. Analytical-sandwich families `{classical, hc2, hc2_bm}` are rejected at `__init__` — the per-unit EIF aggregation has no equivalent single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined. `cluster=` invokes Liang-Zeger CR1 on cluster-aggregated EIF (`_compute_se_from_eif` with `cluster_indices`); `survey_design=` invokes TSL on the combined IF (`_compute_survey_eif_se`); both live in `diff_diff/efficient_did_aggregation.py` since the M-023 post-fit aggregate() extraction. `vcov_type='conley'` deferred to the EfficientDiD Conley follow-up row in DEFERRED.md. - **Note:** Default `cluster=None` (no survey design) renders summary label "HC1 heteroskedasticity-robust" because the per-unit EIF SE `sqrt(mean(EIF²)/n)` is methodologically HC1-style (no Liang-Zeger G/(G-1) finite-sample correction). `EfficientDiDResults.cluster_name` and `n_clusters` stay None under unclustered fits. This diverges from `ImputationDiD` which auto-clusters at unit per Borusyak-Jaravel-Spiess (2024) Theorem 3 — there the default summary renders the CR1 unit-clustered label. - **Note:** `set_params(vcov_type=bad)` raises immediately on EVERY estimator: since the shared `BaseEstimator` mixin (`diff_diff/_base.py`, v4 2(c)-i), `set_params` validates transactionally by constructor probe re-init, so it enforces exactly `__init__`'s validation, eagerly, library-wide. The former split — EfficientDiD eager vs `ImputationDiD`/`TripleDifference`/`CallawaySantAnna` (and six more: SunAbraham, StackedDiD, StaggeredTripleDifference, SpilloverDiD, TROP, PreTrendsPower) accepting constructor-rejected values until `fit()` — is retired; the fit-time re-validation layers remain as a second check against DIRECT attribute mutation (`est.vcov_type = ...`), which no setter can see. -- **Note (post-fit aggregate() - rows M-023/M-120):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work) in favor of post-fit `EfficientDiDResults.aggregate(type, balance_e=)` - a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a StackedDiD/dCDH view relay): `fit()` computes nothing extra, the results object retains an `AggregationKit`, and `aggregate('event_study'/'group', balance_e=)` re-runs the extracted `_EfficientAggregationMixin` aggregators on a throwaway host while `aggregate('simple')` relays the stored overall row bit-exact. (a) RETAINED BUFFERS (memory contract; phrased as maxima - optional design fields stay None when unsupplied): the per-(g,t) EIF dict, O(n_units x n_gt), the dominant payload - retained on EVERY fit regardless of `store_eif`, which since 3.9 governs only the public `influence_functions` field; `unit_cohorts` (cohort labels), `unit_level_weights`, factorized cluster codes - O(n_units) each; on ordinary (TSL) survey fits the unit-level `ResolvedSurveyDesign` adds `weights` plus, where supplied, `strata`/`psu`/`fpc` (factorized int codes / float values, never raw labels) - up to four O(n_units) arrays; on replicate designs it adds the O(n_units x n_replicates) replicate matrix plus, where supplied, `replicate_strata`/`replicate_rscales` (O(n_replicates)); per-row dict SNAPSHOTS of `group_time_effects` plus copies of the `groups`/`time_periods` lists and the scalar `pt_assumption`/`n_treated+n_control` provenance (aggregate() recomputes exclusively from these private snapshots, never from the mutable public result fields - a user edit of the public rows cannot mix altered point estimates with the retained EIF variance); scalars `n_units`, `cohort_fractions`, and the POST-OVERALL `df_survey` snapshot (captured after the overall inference and before the ES/group gates: the group pass can degenerate the working df to None on replicate designs with `n_valid <= 1`, and every fit-time aggregation seeds from the post-overall value, so recompute replays the exact seed). The data-minimization guarantee is scoped to unit identifiers - no unit-label container is retained. (b) `balance_e` uses the ANCHOR-HORIZON rule (keep cohorts with a finite effect at the anchor horizon, then retain all their horizons) - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference (EDiD anchors on the `int(t - g)` bucket, CS on raw `t - g` - identical on integer-period panels; see the truncation Note below), divergent from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits: 'simple' (and 'total') RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the RECOMPUTE levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` (the fit-captured RNG state + run params BY VALUE - seed=None fits replay; post-fit `set_params`/attribute mutation cannot alter it; pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose` - never bit-identity), the percentile p-value is a count statistic (compared at 2/n_bootstrap), and the replay publishes no analytical provenance (percentile overrides via the shared `bootstrap_utils` appliers; group rows' `df_used` cleared exactly as at fit time; EDiD has no sup-t cband). The spec stamps the weight backend at capture ('rust'/'numpy', or 'portable' for backend-independent branches - stratified survey generation, census-FPC zero weights, the single-PSU degenerate early return); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. Replays RE-EMIT the fit-time bootstrap warnings for the replayed configuration (low n_bootstrap, single-PSU, empty balance_e anchor, non-finite-draw RuntimeWarnings); the relay levels stay silent. Per-call cost O(n_bootstrap x n_units x n_gt), no memoization. The M-027 rationale - no level publishes analytical-provenance fields beside percentile inference - is honored by the percentile overrides and NaN df channels. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the primary ground is the absent joint event-study covariance (container `vcov=None`, 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`. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C = sum(n_treated)` over the kept post-anticipation FINITE-effect cells of the kit's deep-copied `group_time_effects` snapshot (an exact integer sum; NEVER the routinely-non-integral `n_units x sum(cohort_fractions)` float product). Unweighted fits only: ANY declared `survey_design=` fails closed via the kit's `unit_level_weights`/`resolved_survey_unit` markers (weight-type-agnostic - unweighted psu-only and analytic fweight designs, whose resolved weights stay RAW, gate identically; EDiD never synthesizes an internal design, so the markers are exact declaration provenance and post-fit mutation of the public `survey_metadata` cannot bypass them). No keepers -> NaN mass -> all-NaN row (no re-warn); with finite `C` the relay is verbatim (inherited NaNs pass through; att/n never blanked). Bootstrap fits relay with a NaN df column (M-027); df carrier = the post-overall `df_survey` snapshot; container: single `target='total'` row, `n_kind='obs'`, the per-level n-semantics note in the CS total Note applies. MMM: the container is admitted with NO scale (see the MMM section); the survey/RC totals remainder and its att*dC variance term are the DEFERRED row. +- **Note (post-fit aggregate() - rows M-023/M-120):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work) in favor of post-fit `EfficientDiDResults.aggregate(type, balance_e=)` - a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a StackedDiD/dCDH view relay): `fit()` computes nothing extra, the results object retains an `AggregationKit`, and `aggregate('event_study'/'group', balance_e=)` re-runs the extracted `_EfficientAggregationMixin` aggregators on a throwaway host while `aggregate('simple')` relays the stored overall row bit-exact. (a) RETAINED BUFFERS (memory contract; phrased as maxima - optional design fields stay None when unsupplied): the per-(g,t) EIF dict, O(n_units x n_gt), the dominant payload - retained on EVERY fit regardless of `store_eif`, which since 3.9 governs only the public `influence_functions` field; `unit_cohorts` (cohort labels), `unit_level_weights`, factorized cluster codes - O(n_units) each; on ordinary (TSL) survey fits the unit-level `ResolvedSurveyDesign` adds `weights` plus, where supplied, `strata`/`psu`/`fpc` (factorized int codes / float values, never raw labels) - up to four O(n_units) arrays; on replicate designs it adds the O(n_units x n_replicates) replicate matrix plus, where supplied, `replicate_strata`/`replicate_rscales` (O(n_replicates)); per-row dict SNAPSHOTS of `group_time_effects` plus copies of the `groups`/`time_periods` lists and the scalar `pt_assumption`/`n_treated+n_control` provenance (aggregate() recomputes exclusively from these private snapshots, never from the mutable public result fields - a user edit of the public rows cannot mix altered point estimates with the retained EIF variance); scalars `n_units`, `cohort_fractions`, and the POST-OVERALL `df_survey` snapshot (captured after the overall inference and before the ES/group gates: the group pass can degenerate the working df to None on replicate designs with `n_valid <= 1`, and every fit-time aggregation seeds from the post-overall value, so recompute replays the exact seed). The data-minimization guarantee is scoped to unit identifiers - no unit-label container is retained. (b) `balance_e` uses the ANCHOR-HORIZON rule (keep cohorts with a finite effect at the anchor horizon, then retain all their horizons) - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference (EDiD anchors on the `int(t - g)` bucket, CS on raw `t - g` - identical on integer-period panels; see the truncation Note below), divergent from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits: 'simple' (and 'total') RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the RECOMPUTE levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` (the fit-captured RNG state + run params BY VALUE - seed=None fits replay; post-fit `set_params`/attribute mutation cannot alter it; pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose` - never bit-identity), the percentile p-value is a count statistic (compared at 2/n_bootstrap), and the replay publishes no analytical provenance (percentile overrides via the shared `bootstrap_utils` appliers; group rows' `df_used` cleared exactly as at fit time; EDiD has no sup-t cband). The spec stamps the weight backend at capture ('rust'/'numpy', or 'portable' for backend-independent branches - stratified survey generation, census-FPC zero weights, the single-PSU degenerate early return); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. Replays RE-EMIT the fit-time bootstrap warnings for the replayed configuration (low n_bootstrap, single-PSU, empty balance_e anchor, non-finite-draw RuntimeWarnings); the relay levels stay silent. Per-call cost O(n_bootstrap x n_units x n_gt), no memoization. The M-027 rationale - no level publishes analytical-provenance fields beside percentile inference - is honored by the percentile overrides and NaN df channels. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the primary ground is the absent joint event-study covariance (container `vcov=None`; the PER-ROW df column now threads the post-overall `df_survey` snapshot via the results-level `event_study_df` channel on analytical survey fits - M-092 completion; None on non-survey/bootstrapped/no-surface fits and for the replicate-undefined 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). Reference semantics are regime-dependent: under `pt_assumption="all"` there is NO reference row (universal first-period baseline; e=-1 is a genuine estimate); under `"post"` the per-cohort baseline cell is materialized as a mechanical zero anchor at `e = -1 - anticipation` whenever it is not the panel's first period, and the MEMBERSHIP-GATED `reference_period` property (the SunAbraham rule - never synthesized when the anchor cell was not estimated) marks it `is_reference` in the container and corrects `plot_event_study`'s inferred reference (previously the `-1` fallback) on PT-Post `anticipation>0` fits. (e) 'simple' relay conventions: `target="att"`, `n = n_treated_units + n_control_units` with `n_kind="units"` (DISJOINT by construction - `last_cohort` trimming reassigns before the counts, so a true total exists, unlike StackedDiD's overlapping sets), `df` = the post-overall snapshot (provenance-exact where `survey_metadata.df_survey` can diverge in the degenerate replicate state); 'group' relay: `n_kind="cells"`, `weight=None` (equal within-cohort weights, no cross-cohort mass), per-row `df_used` array captured at each row's `safe_inference` call (exact by construction; a stated divergence from CS's conservative-min scalar broadcast); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose `survey_metadata` copy carries the snapshot `df_survey` and whose `event_study_df` threads the same snapshot into the per-row df column (cleared on bootstrap replays and for the 0 sentinel). (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C = sum(n_treated)` over the kept post-anticipation FINITE-effect cells of the kit's deep-copied `group_time_effects` snapshot (an exact integer sum; NEVER the routinely-non-integral `n_units x sum(cohort_fractions)` float product). Unweighted fits only: ANY declared `survey_design=` fails closed via the kit's `unit_level_weights`/`resolved_survey_unit` markers (weight-type-agnostic - unweighted psu-only and analytic fweight designs, whose resolved weights stay RAW, gate identically; EDiD never synthesizes an internal design, so the markers are exact declaration provenance and post-fit mutation of the public `survey_metadata` cannot bypass them). No keepers -> NaN mass -> all-NaN row (no re-warn); with finite `C` the relay is verbatim (inherited NaNs pass through; att/n never blanked). Bootstrap fits relay with a NaN df column (M-027); df carrier = the post-overall `df_survey` snapshot; container: single `target='total'` row, `n_kind='obs'`, the per-level n-semantics note in the CS total Note applies. MMM: the container is admitted with NO scale (see the MMM section); the survey/RC totals remainder and its att*dC variance term are the DEFERRED row. - **Note (fractional-period truncation bucketing):** the event-study equation defines `ES(e)` at an exact relative time, but the implementation buckets horizons by `int(t - g)` - truncation TOWARD ZERO - on the analytical aggregator, the bootstrap ES prep (all three keying sites, the `balance_e` anchor filter included; aligned 2026-08), and `hausman_pretest`'s internal aggregation. On integer-period panels `int()` is the identity and nothing below applies. On fractional-period panels (accepted by `fit()` without an integrality check): (i) bucket 0 is DOUBLE-WIDTH, spanning `(-1, 1)`, so the pre-treatment horizon e=-0.5 pools into the post-treatment e=0 bucket; e=1.0/1.5 pool into bucket 1 and e=-1.5/-1.0 into bucket -1; (ii) under `pt_assumption="post"` the bucket at `-1 - anticipation` pools genuine estimated fractional pre-treatment horizons (e.g. raw e=-1.5) with the mechanical zero anchor, and the container's reference normalization then publishes the WHOLE pooled row as `att=0.0` with NaN inference (`is_reference=True`) - genuine fractional-horizon estimates are subsumed into the reference row (the fit-time `event_study_effects` dict keeps the pooled effect + percentile inference unrewritten; the rewrite is container-only); (iii) WEIGHTING is CELL-MASS within the bucket - the aggregator appends one `(effect, pi_g)` term per CELL and normalizes, so a cohort contributing k cells to a bucket carries k*pi_g mass, whereas the exact-relative-time equation has one term per cohort - while `n_groups` counts DISTINCT cohorts in the bucket (identity with the cell count on integer panels). Every aggregation that truncation-buckets a fractional horizon emits a UserWarning naming this Note. - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. @@ -2120,7 +2121,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 fire the aggregate warning; since 3.9 the wrapper itself warns per M-070) 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, of which only finite-tau-hat observations enter the average - `n` reports the raw count, so on partially unidentified fits `n` exceeds the averaged support) 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. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits) and `heterogeneity` checks run without the deprecated fit-time kwarg; the re-emitted recompute warnings are captured and re-published on the consuming report section (record-and-republish), and derivation failures (bootstrap, missing kit, the replicate gate above) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the FINITE-tau complete-case support (`total_support`, a float SNAPSHOT stashed at kit build precisely because the kit's `df` is a live `_fit_data` reference - a post-fit frame edit must not move the total; pre-upgrade kits missing the stash raise the refit message). Since the overall IS the finite-support mean, `C x overall = sum(tau)` exactly - this total uses the FINITE support and thereby fixes the documented raw-|Omega_1| overcount of the MMM `scale=\"auto\"` route for total exports ('simple''s `n` stays the raw count by contract; different levels, different documented n semantics). Zero support (the all-unidentified fit, overall already NaN) -> NaN mass -> all-NaN row (no re-warn); finite `C` relays verbatim - the constant-outcome degenerate fit keeps finite att/se = 0 beside its inherited `(nan, nan)` CI, mirroring 'simple'. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). +- **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 fire the aggregate warning; since 3.9 the wrapper itself warns per M-070) 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 PER-ROW df column now threads the FINAL survey df via the results-level `event_study_df` channel - M-092 completion; `survey_df_final` at fit, the level-matched replay value on replicate replays; leads included on survey fits; None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). (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, of which only finite-tau-hat observations enter the average - `n` reports the raw count, so on partially unidentified fits `n` exceeds the averaged support) 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. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits) and `heterogeneity` checks run without the deprecated fit-time kwarg; the re-emitted recompute warnings are captured and re-published on the consuming report section (record-and-republish), and derivation failures (bootstrap, missing kit, the replicate gate above) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the FINITE-tau complete-case support (`total_support`, a float SNAPSHOT stashed at kit build precisely because the kit's `df` is a live `_fit_data` reference - a post-fit frame edit must not move the total; pre-upgrade kits missing the stash raise the refit message). Since the overall IS the finite-support mean, `C x overall = sum(tau)` exactly - this total uses the FINITE support and thereby fixes the documented raw-|Omega_1| overcount of the MMM `scale=\"auto\"` route for total exports ('simple''s `n` stays the raw count by contract; different levels, different documented n semantics). Zero support (the all-unidentified fit, overall already NaN) -> NaN mass -> all-NaN row (no re-warn); finite `C` relays verbatim - the constant-outcome degenerate fit keeps finite att/se = 0 beside its inherited `(nan, nan)` CI, mirroring 'simple'. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. --- @@ -3040,7 +3041,10 @@ the finite-dimensional `p_0` is handled by the variance correction below. unidentified-variance signal and flows to `safe_inference` on a RETAINED cell), strata/FPC-only designs use the weighted `sqrt(sum(if²))` with the full design entering aggregate SEs via - `_se_from_psi`. Analytical inference uses `df = df_survey` + `_se_from_psi`. Analytical inference uses `df = df_survey` (on bootstrapped + surfaces the container's `df_survey` SCALAR persists while the per-row `df` + column is cleared - the two-channel contract in the CallawaySantAnna + post-fit Note applies to DMLDiD identically) (`n_PSU − n_strata`) t-statistics; bootstrap overrides keep normal theory (the CS convention). On the weighted-λ̂ RCS lane Theorem 2's coverage claim does NOT carry over — the weighted plug-in is validated @@ -4630,7 +4634,7 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - **Note:** Equal-weighting on the continuous path. Paper does not prescribe a unit-weighting scheme on the continuous local-linear paths. Library uses per-unit equal weighting (`w_g = 1` default, matching `diff_diff/_nprobust_port.lprobust`'s default), NOT dose-cell-size weights. Practical consequence: WAS is the population-mean slope from Eq. 3 — `[E(ΔY) − lim_{d↓d̲} E(ΔY | D ≤ d)] / E(D)` (computed as `att = (mean(ΔY) − τ_bc) / mean(D)`), not a cell-size-weighted average; with cell-size weighting, units in less-densely-populated regions of the dose distribution would contribute disproportionately to the boundary slope. User-supplied weights (via `survey_design=SurveyDesign(weights=...)`, pweight) override the equal-weight default and thread through as `W_combined = k((D − d̲)/h) · w_g`. Lock in `tests/test_methodology_had.py::TestHADDeviations::test_equal_weighting_is_per_row_not_per_dose_cell`. - **Note:** Sup-t bootstrap gating. Simultaneous-band sup-t multiplier bootstrap runs in the event-study mode when `cband=True` (default) AND either `survey_design=` is supplied (survey band) OR `cluster=` (cluster-robust band — fires even on an unweighted fit, Phase 2b). The unweighted, unclustered event-study path bit-exactly preserves pre-Phase 4.5 B numerical output (stability invariant). Setting `cband=False` disables the bootstrap on any path. See the algorithmic contract above at `_sup_t_multiplier_bootstrap`. -- **Note (post-fit aggregate() + mode inference - rows M-027/M-139):** `fit(aggregate=)` and `did_had_pretest_workflow(aggregate=)` are deprecated in 3.9 (removed in 4.0). UNLIKE every prior aggregate-postfit adopter, the param was a MODE SELECTOR over mutually exclusive panel shapes ('overall' = exactly two periods -> `HeterogeneousAdoptionDiDResults`; 'event_study' = more than two -> `HeterogeneousAdoptionDiDEventStudyResults`), so the successor is panel-shape INFERENCE: both surfaces resolve the mode from the shared `_infer_aggregate_mode` helper (distinct time values <= 2 -> overall, else event_study), running after the deprecated column-alias reconciliation (the M-035..M-039 renamed-kwarg shims). BEHAVIOR DELTA: a plain multi-period `fit()` (or workflow call) previously raised the two-period shape error and now runs the event-study mode - error -> works; no numbers change on any previously-working call, and a supplied legacy value warns then runs the legacy routing unchanged (invalid values still raise; the one sentinel-reachable shape error is the staggered last-cohort auto-filter dropping a T>2 panel below three periods, its message reworded to panel-shape vocabulary). POST-FIT `aggregate()` is a PURE VIEW with per-class supported subsets - the overall class supports only `'simple'` (one-row bit-exact relay: `target = target_parameter`, the estimand label "WAS"/"WAS_d_lower"; `n = n_obs` contributing units with `n_kind='units'` - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band at the `d_lower` boundary, so `n_obs` is the single source; `df = resolve_inference_df`, provenance-exact: the finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable) and the event-study class supports only `'event_study'` (a `build_event_study_surface` passthrough via the `_from_had` adapter, cband fields included; the adapter's `n_kind` was corrected "obs" -> "units" with this row - `n_obs_per_horizon` counts units). Cross-mode levels fail closed (the two modes share no computable estimand; joint cross-horizon covariance is the DEFERRED.md row). NO kit is retained (views work on pickles from any release) and NO bootstrap gate exists (`n_bootstrap` feeds only the sup-t cband critical value; pointwise inference is always analytical). `AggregationResult.summary()` gained the single-non-'att'-target rendering with this row (target column + neutral 'estimate' heading, column sized to the longest label - previously a WAS or dCDH estimand row rendered under a hard-coded 'ATT' heading; uniform-'att' and ContinuousDiD att/acrt output byte-stable). CONSUMER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (see M-093): HAD's event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (its coefficient is identically zero AND the WAS is not identified there) and no joint covariance exists; both terminal TypeErrors state the deferral in exactly that wording. The `HADPretestReport.aggregate` FIELD survives as output metadata recording which battery ran (only the params die). +- **Note (post-fit aggregate() + mode inference - rows M-027/M-139):** `fit(aggregate=)` and `did_had_pretest_workflow(aggregate=)` are deprecated in 3.9 (removed in 4.0). UNLIKE every prior aggregate-postfit adopter, the param was a MODE SELECTOR over mutually exclusive panel shapes ('overall' = exactly two periods -> `HeterogeneousAdoptionDiDResults`; 'event_study' = more than two -> `HeterogeneousAdoptionDiDEventStudyResults`), so the successor is panel-shape INFERENCE: both surfaces resolve the mode from the shared `_infer_aggregate_mode` helper (distinct time values <= 2 -> overall, else event_study), running after the deprecated column-alias reconciliation (the M-035..M-039 renamed-kwarg shims). BEHAVIOR DELTA: a plain multi-period `fit()` (or workflow call) previously raised the two-period shape error and now runs the event-study mode - error -> works; no numbers change on any previously-working call, and a supplied legacy value warns then runs the legacy routing unchanged (invalid values still raise; the one sentinel-reachable shape error is the staggered last-cohort auto-filter dropping a T>2 panel below three periods, its message reworded to panel-shape vocabulary). POST-FIT `aggregate()` is a PURE VIEW with per-class supported subsets - the overall class supports only `'simple'` (one-row bit-exact relay: `target = target_parameter`, the estimand label "WAS"/"WAS_d_lower"; `n = n_obs` contributing units with `n_kind='units'` - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band at the `d_lower` boundary, so `n_obs` is the single source; `df = resolve_inference_df`, provenance-exact: the finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable) and the event-study class supports only `'event_study'` (a `build_event_study_surface` passthrough via the `_from_had` adapter, cband fields included; the adapter's `n_kind` was corrected "obs" -> "units" with this row - `n_obs_per_horizon` counts units; the adapter now also threads the scalar `event_study_df` - the unit-level design's survey df governing every horizon's t-inference - into the container's per-row df column, M-092 completion; None on non-survey fits, and no bootstrap gate applies since pointwise inference is always analytical). Cross-mode levels fail closed (the two modes share no computable estimand; joint cross-horizon covariance is the DEFERRED.md row). NO kit is retained (views work on pickles from any release) and NO bootstrap gate exists (`n_bootstrap` feeds only the sup-t cband critical value; pointwise inference is always analytical). `AggregationResult.summary()` gained the single-non-'att'-target rendering with this row (target column + neutral 'estimate' heading, column sized to the longest label - previously a WAS or dCDH estimand row rendered under a hard-coded 'ATT' heading; uniform-'att' and ContinuousDiD att/acrt output byte-stable). CONSUMER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (see M-093): HAD's event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (its coefficient is identically zero AND the WAS is not identified there) and no joint covariance exists; both terminal TypeErrors state the deferral in exactly that wording. The `HADPretestReport.aggregate` FIELD survives as output metadata recording which battery ran (only the params die). - **Note:** Pierce-Schott (2016) Figure 2 replication harness deferred. The paper's empirical application self-acknowledges (Section 5.2; mirrored in `dechaisemartin-2026-review.md:321`) that "NP estimators are too noisy to be informative" on the LBD-restricted PNTR panel. R parity at `atol=1e-8` on 3 DGPs × 5 method combos via `tests/test_did_had_parity.py` (bit-exact, `rtol=0`) is a stronger correctness anchor than reproducing pointwise CIs on LBD-restricted data. **Scope caveat:** R parity locks point estimate, SE, and CI bounds bit-exactly to R's bounds — it does NOT independently verify the asymptotic-coverage properties of the bias-corrected CI in small samples. Paper Table 1 documents under-coverage at small G (89% at G=100 on DGP 1, 93% at G=500, 95% at G=2500); this is inherited from the CCF asymptotic theory itself, and Python is exact-parity with R at the limit-law machinery. - **Note:** Table 1 coverage-rate reproduction deferred. Paper Section 3.1.5 reports 2,000-iter Monte Carlo coverage rates at `G ∈ {100, 500, 2500}` on DGPs 1/2/3. The existing `tests/test_did_had_parity.py` R parity at `atol=1e-8` on the same 3 DGPs reproduces the exact point estimate and SE algorithm to bit-exact tolerance; coverage-rate MC would re-verify the CCF asymptotic coverage already pinned by R parity (Python ≡ R ≡ paper) at the sample-mean level. **Scope caveat (mirrors above):** R parity does NOT re-prove asymptotic-coverage at small G; paper Table 1's 89% / 93% / 95% under-coverage band is valid for both R and Python. - **Library extension:** Staggered-timing fail-closed. Paper Appendix B.2 prescribes "Warn" when staggered treatment timing is detected; library raises `ValueError` at `diff_diff/had.py:1624` when multiple first-treat cohorts are detected without `first_treat`. Library extension toward stricter safety: `UserWarning` would let the silent-misuse bug class through (HAD's Appendix B.2 only identifies the LAST cohort under staggered timing); fail-closed forces the user to either supply `first_treat` (which activates auto-filter to last-cohort + never-treated per Appendix B.2) or redirect to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Lock in `tests/test_methodology_had.py::TestHADDeviations`. diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index 3fd61929a..8741485d8 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -1002,7 +1002,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. 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. Amended pre-cut a fifth time (Phase 3(a), with M-010): TWO optional provenance fields appended last - post_periods (the producer's AUTHORITATIVE calendar partition, an arbitrary subset being legal on MultiPeriodDiD, so consumers must never re-derive it positionally; threaded by _from_mpd and the TWFE event-study producer, None from every other builder; content-validated in __post_init__: nonempty, no duplicates, subset of event_time, disjoint from reference rows) and estimation_spec ('within'|'pooled', TWFE producer only, value-validated per the n_kind off-vocabulary precedent) - both routed through to_dict() with JSON-safe labels and consumed by the HonestDiD/PreTrendsPower calendar routes and the plotter's partition-aware split; same test_ref extended." + 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. Amended pre-cut a fifth time (Phase 3(a), with M-010): TWO optional provenance fields appended last - post_periods (the producer's AUTHORITATIVE calendar partition, an arbitrary subset being legal on MultiPeriodDiD, so consumers must never re-derive it positionally; threaded by _from_mpd and the TWFE event-study producer, None from every other builder; content-validated in __post_init__: nonempty, no duplicates, subset of event_time, disjoint from reference rows) and estimation_spec ('within'|'pooled', TWFE producer only, value-validated per the n_kind off-vocabulary precedent) - both routed through to_dict() with JSON-safe labels and consumed by the HonestDiD/PreTrendsPower calendar routes and the plotter's partition-aware split; same test_ref extended. QUALIFIERS RESOLVED (3.12, M-092 completion): the four SCOPE-QUALIFIER producers above (EfficientDiD, ImputationDiD, ContinuousDiD, HeterogeneousAdoptionDiD) now expose a results-level event_study_df scalar threaded into the container's per-row df column - finite on analytical survey fits (EDiD: the post-overall snapshot; Imputation: the FINAL survey df, level-matched on replicate replays, leads included; ContinuousDiD: the kit survey_df; HAD: the unit-level design df via _from_had), None on non-survey/bootstrapped/no-surface fits and for the replicate-undefined 0 sentinel. The formerly-named TODO.md completion row is CLOSED; per-row df pins live in tests/test_aggregate_contract.py (TestPerRowDfProvenanceCompletion)." - id: M-093 kind: behavior group: results-contract @@ -1013,7 +1013,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. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. SIXTH PRE-CUT AMENDMENT (Phase 3(a), with M-010): admission widened to TWFE-SOURCED CALENDAR containers in compute_honest_did + compute_pretrends_power via a dedicated calendar route that reconstructs the native MultiPeriodDiDResults-branch inputs from the container's post_periods partition provenance (single-reference gate; HonestDiD additionally GEOMETRY-SCOPED to chronological partitions - suffix post_periods + last-pre reference - with the pre-existing native non-suffix geometry limitation documented in the REGISTRY HonestDiD Note and the DEFERRED.md transform-or-reject row; PreTrendsPower plumbs reference_period through the gamma-unit helper, string-label degradation included); relative-scale admission unchanged. SEVENTH PRE-CUT AMENDMENT (DML PR-B1): admission widened to DMLDiD-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults, DMLDiDResults}): DMLDiDResults subclasses CallawaySantAnnaResults and its aggregate('event_study') container is built by the INHERITED CS kit channel, so it carries the same staggered ATT(g,t) payload, reference semantics, base_period provenance, and (bootstrapped) replay behavior as a CS container - the same-semantics ground the original CS admission rests on; the producer-derived guard messages treat DMLDiDResults like CS (both expose base_period). DMLDiD adds NO builder (M-092 untouched) and never populates fit-time event_study_effects (its sentinel-retirement obligation is discharged by the shared CS code). Source scoping continues to reject all other producers. This row stays planned: its transition remains the 4.0 sentinel retirement + full-producer schema enforcement." + notes: "4.0 sentinel retirement + schema enforcement (spec section 5): the n_groups==0 / n_obs==0 reference-row sentinels retire; every estimator's to_dataframe(level='event_study') emits the [M-092] column schema; the plotter / HonestDiD / PreTrendsPower consume the unified surface. Thirteen ES-carrying source modules enumerated file-by-file (results.py covers MultiPeriodDiD + SpilloverDiD) plus the plotter. behavior-at-done requires test_ref. PARTIAL PRE-CUT DELIVERY (2(b) PR-1, with M-026): the consumer half shipped for CS-SOURCED containers - compute_honest_did, compute_pretrends_power (both with SOURCE-SCOPED admission: source == CallawaySantAnnaResults only, rejecting dCDH l1 containers BY DESIGN since their placebo semantics need honest_did's native branch, and every other producer pending its own aggregate() migration) and plot_event_study / plot_honest_event_study (no source scoping - plotting is label-faithful). Admission widening is each later shim PR's methodology decision, not automatic. SECOND PRE-CUT AMENDMENT (2(b) PR-2, with M-024): admission widened to STACKED-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults}; kappa_pre >= 2 required for estimated pre-periods; honest additionally needs a non-singular full retained event-study covariance (pre+post sub-block); withheld-inference rows admitted with a source-scoped warning; the seven producer-derived guard messages now derive the producer from surface.source). Source scoping continues for all other producers. THIRD PRE-CUT AMENDMENT (2(b) PR-3a, with M-023): admission NOT widened to EfficientDiD-sourced containers BY DESIGN (the dCDH precedent) - the PRIMARY ground is the absent joint event-study covariance (container vcov=None; per-row df was all-NaN at that amendment and is finite on analytical survey fits since the 3.12 M-092 completion - the rejection ground is the missing joint covariance, unchanged); reference semantics are regime-dependent (PT-All: no reference row, universal first-period baseline; PT-Post: a materialized mechanical zero anchor at e=-1-anticipation when estimated, marked via the membership-gated reference_period property - never fabricated when absent); both terminal TypeErrors now state the EfficientDiD rejection explicitly; source scoping continues to reject. FOURTH PRE-CUT AMENDMENT (2(b) PR-3b, with M-021/M-022): admission NOT widened by the Imputation/TwoStage migrations - ImputationDiD rejected BY DESIGN (no joint ES covariance; per-horizon Theorem-3 conservative SEs only); TwoStageDiD DEFERRED despite carrying the joint Gardner-GMM covariance on analytical fits (M-092), because its pre-period coefficients are stage-1 residual means (the reference horizon is dropped from the no-intercept Stage-2 design; the zero anchor row is appended mechanically), not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the delta_0=0 normalization into its boundary/bridge constraints - admission awaits a normalization derivation (DEFERRED.md paper-gated row); both terminal TypeErrors state the per-producer grounds. FIFTH PRE-CUT AMENDMENT (2(b) PR-3c, with M-025): admission NOT widened by the ContinuousDiD migration - rejected BY DESIGN on two independent grounds: no joint event-study covariance exists (per-bin IF SEs only), and the binarized bins carry NO reference-period normalization at all (no reference row exists in the surface; each bin is a raw reweighted binarized ATT level), so HonestDiD's delta_0=0 arithmetic has no anchor even in principle without new methodology; both terminal TypeErrors name the ContinuousDiD rejection alongside EfficientDiD/Imputation. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. SIXTH PRE-CUT AMENDMENT (Phase 3(a), with M-010): admission widened to TWFE-SOURCED CALENDAR containers in compute_honest_did + compute_pretrends_power via a dedicated calendar route that reconstructs the native MultiPeriodDiDResults-branch inputs from the container's post_periods partition provenance (single-reference gate; HonestDiD additionally GEOMETRY-SCOPED to chronological partitions - suffix post_periods + last-pre reference - with the pre-existing native non-suffix geometry limitation documented in the REGISTRY HonestDiD Note and the DEFERRED.md transform-or-reject row; PreTrendsPower plumbs reference_period through the gamma-unit helper, string-label degradation included); relative-scale admission unchanged. SEVENTH PRE-CUT AMENDMENT (DML PR-B1): admission widened to DMLDiD-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults, DMLDiDResults}): DMLDiDResults subclasses CallawaySantAnnaResults and its aggregate('event_study') container is built by the INHERITED CS kit channel, so it carries the same staggered ATT(g,t) payload, reference semantics, base_period provenance, and (bootstrapped) replay behavior as a CS container - the same-semantics ground the original CS admission rests on; the producer-derived guard messages treat DMLDiDResults like CS (both expose base_period). DMLDiD adds NO builder (M-092 untouched) and never populates fit-time event_study_effects (its sentinel-retirement obligation is discharged by the shared CS code). Source scoping continues to reject all other producers. 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 diff --git a/tests/test_aggregate_contract.py b/tests/test_aggregate_contract.py index d494527e3..6aaeace93 100644 --- a/tests/test_aggregate_contract.py +++ b/tests/test_aggregate_contract.py @@ -5516,3 +5516,244 @@ def test_degenerate_inference_passes_through(self): assert np.isfinite(tot.n[0]) and tot.n[0] > 0 assert tot.att[0] == tot.n[0] * simple.att[0] assert tot.se[0] == 0.0 + + +# --------------------------------------------------------------------------- +# M-092 completion: per-row ES df threading + the df_survey scalar contract +# --------------------------------------------------------------------------- + + +from diff_diff.results_base import build_event_study_surface # noqa: E402 + + +class TestPerRowDfProvenanceCompletion: + """Per-row ES df threading for the four former M-092 holes (EfficientDiD, + ImputationDiD, ContinuousDiD, HAD) and the documented two-channel + contract: the scalar ``df_survey`` (the fit's resolved scalar inference + df) persists on bootstrapped surfaces, while the per-row ``df`` column is + the inference-provenance channel and is cleared under percentile + bootstrap (M-027).""" + + # ---- EfficientDiD ------------------------------------------------- + + def test_edid_es_per_row_df_survey_tsl_oracle(self): + from diff_diff.utils import safe_inference + + d, _ = _efficient_survey_panel() + sd = _efficient_survey_design() + plain = _fit_efficient(d, survey_design=sd) + expected = plain._aggregation_kit.bookkeeping["df_survey"] + # Modern fits build no fit-time ES surface -> attr None (absent- + # surface gate); the deprecated fit-time route carries the value. + assert plain.event_study_df is None + fit_time = _fit_efficient(d, survey_design=sd, aggregate="event_study") + assert fit_time.event_study_df == expected + es = plain.aggregate("event_study") + finite = np.isfinite(np.asarray(es.df, dtype=float)) + assert finite.any() + assert set(np.unique(np.asarray(es.df, dtype=float)[finite])) == {float(expected)} + # Semantic oracle: safe_inference(att, se, df=row.df) reproduces the + # stored per-row inference (a mis-seeded df would mismatch p). + for i in np.flatnonzero(finite): + t, p, _ = safe_inference( + float(es.att[i]), float(es.se[i]), alpha=plain.alpha, df=float(es.df[i]) + ) + np.testing.assert_allclose(t, es.t_stat[i], rtol=1e-12) + np.testing.assert_allclose(p, es.p_value[i], rtol=1e-12) + + def test_edid_es_per_row_df_replicate_arms(self): + from diff_diff.utils import safe_inference + + for degenerate, expected_df in ((None, 7.0), ("dropped", 5.0)): + d, rep_cols = _efficient_survey_panel(replicate=True, degenerate=degenerate) + sd = _efficient_survey_design(rep_cols) + plain = _fit_efficient(d, survey_design=sd) + fit_time = _fit_efficient(d, survey_design=sd, aggregate="event_study") + assert fit_time.event_study_df == expected_df + es = plain.aggregate("event_study") + df_col = np.asarray(es.df, dtype=float) + finite = np.isfinite(df_col) + assert set(np.unique(df_col[finite])) <= {expected_df} + for i in np.flatnonzero(finite): + t, p, _ = safe_inference( + float(es.att[i]), float(es.se[i]), alpha=plain.alpha, df=float(es.df[i]) + ) + np.testing.assert_allclose(p, es.p_value[i], rtol=1e-12) + + def test_edid_replicate_undefined_zero_sentinel_is_none(self): + # 0-sentinel normalization: the replicate-undefined df publishes the + # ATTRIBUTE as None, never 0 (the sentinel stays representable only + # via survey_metadata). + d, rep_cols = _efficient_survey_panel(replicate=True, degenerate="undefined") + sd = _efficient_survey_design(rep_cols) + fit_time = _fit_efficient(d, survey_design=sd, aggregate="event_study") + assert fit_time.event_study_df is None + + def test_edid_bootstrap_clears_per_row_df_scalar_persists(self): + d, _ = _efficient_survey_panel() + sd = _efficient_survey_design() + boot = _efficient_boot_fit(d, survey_design=sd) + # Fit-time clearing: percentile inference used no df. + assert boot.event_study_df is None + es = boot.aggregate("event_study") + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + # Row B: the scalar df_survey persists beside percentile inference. + assert es.df_survey is not None and np.isfinite(es.df_survey) + assert es.df_survey == boot.survey_metadata.df_survey + # Fit-time (deprecated route) surface behaves identically. + fit_time = _efficient_boot_fit_time(d, aggregate="event_study", survey_design=sd) + surface = build_event_study_surface(fit_time) + assert np.all(np.isnan(np.asarray(surface.df, dtype=float))) + assert surface.df_survey == fit_time.survey_metadata.df_survey + + def test_edid_absent_es_surface_gate(self, efficient_panel): + plain = _fit_efficient(efficient_panel) + assert plain.event_study_effects is None + assert plain.event_study_df is None + + # ---- ImputationDiD ------------------------------------------------ + + def test_imputation_es_per_row_df_covers_leads(self): + from diff_diff.utils import safe_inference + + d, _ = _imputation_survey_panel() + sd = _imputation_survey_design() + plain = _fit_imputation(d, survey_design=sd, est_kw={"pretrends": True}) + expected = plain.survey_metadata.df_survey + fit_time = _fit_imputation( + d, survey_design=sd, aggregate="event_study", est_kw={"pretrends": True} + ) + assert fit_time.event_study_df == expected + es = plain.aggregate("event_study") + df_col = np.asarray(es.df, dtype=float) + ev = np.asarray(es.event_time) + finite = np.isfinite(df_col) + # Leads included: at least one pre-period row carries the finite df. + assert finite.any() and (ev[finite] < 0).any() + assert set(np.unique(df_col[finite])) == {float(expected)} + for i in np.flatnonzero(finite): + t, p, _ = safe_inference( + float(es.att[i]), float(es.se[i]), alpha=plain.alpha, df=float(es.df[i]) + ) + np.testing.assert_allclose(p, es.p_value[i], rtol=1e-12) + + def test_imputation_replicate_replay_df_is_level_matched(self): + # The replay carrier's per-row df is the REPLAYED level-matched value + # (== the replay surface's own df_survey), which can diverge from the + # fit-time [overall]-stack snapshot on the cohort_zero shape. + d, rep_cols = _imputation_survey_panel(replicate=True, degenerate="cohort_zero") + sd = _imputation_survey_design(rep_cols) + plain = _fit_imputation(d, survey_design=sd) + es = plain.aggregate("event_study") + df_col = np.asarray(es.df, dtype=float) + finite = np.isfinite(df_col) + assert finite.any() + assert set(np.unique(df_col[finite])) == {float(es.df_survey)} + + def test_imputation_replicate_undefined_zero_sentinel_is_none(self): + d, rep_cols = _imputation_survey_panel(replicate=True, degenerate="undefined") + sd = _imputation_survey_design(rep_cols) + fit_time = _fit_imputation(d, survey_design=sd, aggregate="event_study") + assert fit_time.event_study_df is None + + def test_imputation_bootstrap_clears_at_fit_and_replay_fails_closed(self): + d, _ = _imputation_survey_panel() + sd = _imputation_survey_design() + boot = _fit_imputation(d, survey_design=sd, est_kw={"n_bootstrap": 30, "seed": 1}) + assert boot.event_study_df is None + with pytest.raises(NotImplementedError): + boot.aggregate("event_study") + + # ---- ContinuousDiD ------------------------------------------------ + + def test_cont_es_per_row_df_threads_survey_df(self): + d, _ = _cont_survey_panel() + sd = _cont_survey_design() + plain = _fit_cont(d, survey_design=sd) + expected = plain._aggregation_kit.bookkeeping["survey_df"] + assert plain.event_study_df is None # no fit-time ES surface + es = plain.aggregate("event_study") + df_col = np.asarray(es.df, dtype=float) + finite = np.isfinite(df_col) + assert finite.any() + assert set(np.unique(df_col[finite])) == {float(expected)} + # Route parity: the fit-time surface carries the same column. + fit_time = _fit_cont(d, survey_design=sd, aggregate="eventstudy") + surface = build_event_study_surface(fit_time) + np.testing.assert_array_equal( + np.asarray(surface.df, dtype=float), np.asarray(es.df, dtype=float) + ) + + def test_cont_bootstrap_fit_clears_df_scalar_is_unit_level(self): + # Bootstrapped fit: _survey_df stays None -> attr None; the fit-time + # surface's per-row df is all-NaN while the df_survey scalar reports + # the (now unit-level) design df - the Row A container pin on an + # implicit-PSU design. + d, _ = _cont_survey_panel() + from diff_diff import SurveyDesign + + sd = SurveyDesign(weights="w") # implicit-PSU + boot = _fit_cont( + d, + survey_design=sd, + aggregate="eventstudy", + est_kw={"n_bootstrap": 20, "seed": 3}, + ) + assert boot.event_study_df is None + surface = build_event_study_surface(boot) + assert np.all(np.isnan(np.asarray(surface.df, dtype=float))) + # Unit-level scalar: n_units - 1 (implicit PSU = unit), not obs-level. + n_units = boot.survey_metadata.n_psu + assert surface.df_survey == boot.survey_metadata.df_survey == n_units - 1 + + def test_cont_absent_es_surface_gate(self, cont_panel): + plain = _fit_cont(cont_panel) + assert plain.event_study_effects is None + assert plain.event_study_df is None + + # ---- HAD ---------------------------------------------------------- + + def test_had_es_per_row_df_survey(self): + from diff_diff import SurveyDesign + from diff_diff.utils import safe_inference + + d = _had_panel_multi() + rng = np.random.default_rng(7) + wmap = {u: rng.uniform(0.5, 2.0) for u in d["unit"].unique()} + d = d.assign(w=d["unit"].map(wmap), stratum=d["unit"] % 4) + res = _fit_had(d, survey_design=SurveyDesign(weights="w", strata="stratum")) + expected = res.survey_metadata.df_survey + assert res.event_study_df == expected + es = res.aggregate("event_study") + df_col = np.asarray(es.df, dtype=float) + finite = np.isfinite(df_col) + assert finite.any() + assert set(np.unique(df_col[finite])) == {float(expected)} + for i in np.flatnonzero(finite): + t, p, _ = safe_inference( + float(es.att[i]), float(es.se[i]), alpha=res.alpha, df=float(es.df[i]) + ) + np.testing.assert_allclose(p, es.p_value[i], rtol=1e-12) + # to_dict carries the new field. + assert res.to_dict()["event_study_df"] == expected + + def test_had_non_survey_df_stays_nan(self): + res = _fit_had(_had_panel_multi()) + assert res.event_study_df is None + es = res.aggregate("event_study") + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + + # ---- Row B scalar contract, CS side -------------------------------- + + def test_cs_bootstrapped_scalar_persists_per_row_cleared(self): + d = _survey_panel() + sd = _survey_design() + boot = _boot_fit(d, fit_kwargs={"survey_design": sd}) + es = boot.aggregate("event_study") + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + assert es.df_survey is not None and np.isfinite(es.df_survey) + assert es.df_survey == boot.survey_metadata.df_survey + fit_time = _boot_fit_time(d, aggregate="event_study", fit_kwargs={"survey_design": sd}) + surface = build_event_study_surface(fit_time) + assert np.all(np.isnan(np.asarray(surface.df, dtype=float))) + assert surface.df_survey == fit_time.survey_metadata.df_survey diff --git a/tests/test_survey_dml.py b/tests/test_survey_dml.py index d36369972..43a13e6bd 100644 --- a/tests/test_survey_dml.py +++ b/tests/test_survey_dml.py @@ -818,6 +818,17 @@ def test_event_study_df_threads_kit_df_survey(self, panel_survey): assert finite.size > 0 assert np.all(finite == panel_survey.survey_metadata.df_survey) + def test_bootstrap_replay_scalar_persists_per_row_cleared(self, panel_df, ci_params): + # Two-channel contract on the bootstrap REPLAY (DMLDiD has no + # fit-time ES surface): the per-row df column is all-NaN beside + # percentile inference (M-027) while the df_survey scalar - the + # fit's resolved scalar inference df - persists. + boot = _fit(panel_df, survey=_DESIGN, n_bootstrap=ci_params.bootstrap(29)) + es = boot.aggregate("event_study") + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + assert es.df_survey is not None and np.isfinite(es.df_survey) + assert es.df_survey == boot.survey_metadata.df_survey + def test_describe_target_parameter_names_cohort_masses(self, panel_survey, panel_plain): from diff_diff._reporting_helpers import describe_target_parameter diff --git a/tests/test_survey_metadata_raw_scale.py b/tests/test_survey_metadata_raw_scale.py index f2985da91..751e3d064 100644 --- a/tests/test_survey_metadata_raw_scale.py +++ b/tests/test_survey_metadata_raw_scale.py @@ -471,15 +471,70 @@ def test_analytic_metadata_uses_raw_unit_weights_of_filtered_frame( res = self._fit(cont_df, _DESIGN, n_bootstrap=0) _assert_raw_scale(res.survey_metadata, cont_filtered.groupby("unit")["weight"].first()) - def test_bootstrap_metadata_stays_obs_level_raw(self, cont_df, cont_filtered): - # Characterization pin for the deferred analytic-vs-bootstrap - # granularity divergence (TODO.md): the bootstrap branch keeps the - # OBS-level raw metadata from the (re-)resolve on the filtered frame. + def test_bootstrap_metadata_is_unit_level_raw(self, cont_df, cont_filtered): + # The bootstrap branch publishes the SAME unit-level raw metadata as + # the analytic branch (one granularity across all inference arms — + # the CS/EfficientDiD convention). res = self._fit(cont_df, _DESIGN, n_bootstrap=20, seed=42) - w = cont_filtered["weight"] - md = res.survey_metadata - np.testing.assert_allclose(md.sum_weights, w.sum(), rtol=1e-12) - np.testing.assert_allclose(md.weight_range, (w.min(), w.max()), rtol=1e-12) + _assert_raw_scale(res.survey_metadata, cont_filtered.groupby("unit")["weight"].first()) + + def test_analytic_bootstrap_metadata_parity(self, cont_df): + # Full-field metadata parity across inference branches, including an + # implicit-PSU design where df_survey/n_psu formerly diverged + # (obs- vs unit-level). + for design in (_DESIGN, SurveyDesign(weights="weight")): + r_a = self._fit(cont_df, design, n_bootstrap=0) + r_b = self._fit(cont_df, design, n_bootstrap=15, seed=42) + ma, mb = r_a.survey_metadata, r_b.survey_metadata + for f in ( + "sum_weights", + "weight_range", + "effective_n", + "design_effect", + "df_survey", + "n_strata", + "n_psu", + ): + np.testing.assert_allclose( + np.asarray(getattr(ma, f), dtype=float), + np.asarray(getattr(mb, f), dtype=float), + rtol=1e-12, + err_msg=f, + ) + + def test_empty_post_cells_metadata_is_unit_level(self): + # Degenerate no-post-cells arm: the hoisted recompute must cover it + # too (no third granularity state). Treated cohort starts after the + # last observed period -> zero post cells, NaN overall. + rng = np.random.default_rng(9) + n_u, n_t = 40, 3 + units = np.repeat(range(n_u), n_t) + times = np.tile(range(1, n_t + 1), n_u) + ft = np.repeat(np.where(np.arange(n_u) < 20, 9, 0), n_t) # post-sample + dose = np.repeat(np.where(np.arange(n_u) < 20, rng.uniform(0.5, 2.0, n_u), 0.0), n_t) + w = np.repeat(rng.uniform(0.5, 2.0, n_u), n_t) + df = pd.DataFrame( + { + "unit": units, + "time": times, + "first_treat": ft, + "dose": dose, + "outcome": rng.normal(size=len(units)), + "weight": w, + } + ) + res = _fit_quiet( + ContinuousDiD(n_bootstrap=0), + df, + "outcome", + "unit", + "time", + "first_treat", + "dose", + survey_design=SurveyDesign(weights="weight"), + ) + assert np.isnan(res.overall_att) + _assert_raw_scale(res.survey_metadata, df.groupby("unit")["weight"].first()) def test_weight_column_aliasing_dose_reports_original_values(self): # weights == dose: the never-treated nonzero-dose coercion zeroes the From ab326915b21cbc2bb888f5072e80388d1c3f0601 Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 29 Aug 2026 14:58:49 -0400 Subject: [PATCH 2/2] perf(continuous_did): build the unit-level resolved design once per fit; docs: event_study_df in llms-full tables CI review round 1: the fit-level metadata hoist, _compute_analytical_se, and _run_bootstrap each collapsed the panel design to unit level (replicate designs copied the unit-by-replicate matrix twice) - build once and thread as pre_unit_resolved (byte-identical outputs, verified against the captured baseline). Add event_study_df rows to the shared staggered and ContinuousDiD llms-full.txt field tables. --- diff_diff/continuous_did.py | 43 ++++++++++++++++++++++++++-------- diff_diff/guides/llms-full.txt | 2 ++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/diff_diff/continuous_did.py b/diff_diff/continuous_did.py index b3f9ae516..d419f26e1 100644 --- a/diff_diff/continuous_did.py +++ b/diff_diff/continuous_did.py @@ -999,13 +999,19 @@ def fit( # sum_weights/effective_n/n_psu/df_survey describe one granularity — # the CS/EfficientDiD convention. Construction is byte-identical to # the ones inside _run_bootstrap and _compute_analytical_se. - if resolved_survey is not None and survey_metadata is not None: - from diff_diff.survey import compute_survey_metadata - - _unit_resolved_meta = resolved_survey.subset_to_units_by_row_idx( + _unit_resolved_shared = None + if resolved_survey is not None: + # Built ONCE and threaded into the analytical/bootstrap helpers + # below (they previously rebuilt it — on replicate designs that + # copied the unit-by-replicate matrix twice). + _unit_resolved_shared = resolved_survey.subset_to_units_by_row_idx( precomp["unit_first_panel_row"], unit_weights=precomp.get("unit_survey_weights"), ) + if resolved_survey is not None and survey_metadata is not None: + from diff_diff.survey import compute_survey_metadata + + _unit_resolved_meta = _unit_resolved_shared # Raw (pre-normalization) unit weights for metadata provenance: # compute_survey_metadata expects the ORIGINAL scale (resolve() # rescales pweights to mean 1; scale-invariant fields are @@ -1088,6 +1094,7 @@ def fit( agg_acrt_d, event_study_effects, resolved_survey=resolved_survey, + pre_unit_resolved=_unit_resolved_shared, ) att_d_se = boot_result["att_d_se"] att_d_ci_lower = boot_result["att_d_ci_lower"] @@ -1128,6 +1135,7 @@ def fit( agg_att_d, agg_acrt_d, resolved_survey=resolved_survey, + pre_unit_resolved=_unit_resolved_shared, ) att_d_se = analytic["att_d_se"] acrt_d_se = analytic["acrt_d_se"] @@ -1994,6 +2002,7 @@ def _compute_analytical_se( agg_att_d: np.ndarray, agg_acrt_d: np.ndarray, resolved_survey: Optional["ResolvedSurveyDesign"] = None, + pre_unit_resolved: Optional["ResolvedSurveyDesign"] = None, ) -> Dict[str, Any]: """Compute analytical SEs using influence functions.""" n_units = precomp["n_units"] @@ -2094,9 +2103,16 @@ def _compute_analytical_se( # The resolved_survey has panel-level arrays (n_obs = n_units * n_periods), # but influence functions are unit-level (n_units). Build a unit-level # ResolvedSurveyDesign by subsetting to one obs per unit. - row_idx = precomp["unit_first_panel_row"] - unit_resolved = resolved_survey.subset_to_units_by_row_idx( - row_idx, unit_weights=precomp.get("unit_survey_weights") + # Reuse the fit-level collapse when supplied (avoids copying + # the unit-by-replicate matrix a second time on replicate + # designs); construction is byte-identical. + unit_resolved = ( + pre_unit_resolved + if pre_unit_resolved is not None + else resolved_survey.subset_to_units_by_row_idx( + precomp["unit_first_panel_row"], + unit_weights=precomp.get("unit_survey_weights"), + ) ) X_ones = np.ones((n_units, 1)) @@ -2194,6 +2210,7 @@ def _run_bootstrap( original_acrt_d: np.ndarray, event_study_effects: Optional[Dict[int, Dict]], resolved_survey: Optional["ResolvedSurveyDesign"] = None, + pre_unit_resolved: Optional["ResolvedSurveyDesign"] = None, ) -> Dict[str, Any]: """Run multiplier bootstrap inference.""" if self.n_bootstrap < 50: @@ -2224,9 +2241,15 @@ def _run_bootstrap( # Build unit-level ResolvedSurveyDesign for survey-aware bootstrap unit_resolved = None if resolved_survey is not None: - row_idx = precomp["unit_first_panel_row"] - unit_resolved = resolved_survey.subset_to_units_by_row_idx( - row_idx, unit_weights=precomp.get("unit_survey_weights") + # Reuse the fit-level collapse when supplied (byte-identical + # construction; avoids a second unit-by-replicate copy). + unit_resolved = ( + pre_unit_resolved + if pre_unit_resolved is not None + else resolved_survey.subset_to_units_by_row_idx( + precomp["unit_first_panel_row"], + unit_weights=precomp.get("unit_survey_weights"), + ) ) # Generate bootstrap weights — PSU-level when survey design is present diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index f2a9a3a82..0d658bda0 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -1991,6 +1991,7 @@ ImputationDiDResults, TwoStageDiDResults, StackedDiDResults, and EfficientDiDRes | `overall_p_value` | `float` | P-value | | `overall_conf_int` | `tuple[float, float]` | CI | | `event_study_effects` | `dict[int, dict]` | Event study effects (if the DEPRECATED fit-time aggregate included event_study; ALWAYS populated for StackedDiD since 3.9 - row M-024; post-fit `aggregate('event_study')` returns a CONTAINER, it does not populate this field) | +| `event_study_df` | `float | dict | None` | Producer-side per-row df channel for the unified event-study container. On `EfficientDiDResults`/`ImputationDiDResults` (3.12, M-092 completion): the scalar survey df governing the ES rows' t-inference (ImputationDiD threads the FINAL replicate-override value; leads included on survey fits); `None` on non-survey fits, on bootstrapped fits (percentile inference used no df), when no fit-time ES surface was built, and for the replicate-undefined `0` sentinel | | `group_effects` | `dict` | Group-level effects (if the deprecated fit-time aggregate included group; post-fit `aggregate('group')` returns an AggregationResult container) | | `groups` | `list` | Treatment cohorts | | `time_periods` | `list` | All time periods | @@ -2035,6 +2036,7 @@ Each event study effect dict contains: `effect`, `se`, `t_stat`, `p_value`, `con | `n_treated_units` | `int` | Treated units | | `n_control_units` | `int` | Control units | | `event_study_effects` | `dict[int, dict] or None` | Event study effects (populated only by the DEPRECATED fit-time `aggregate="eventstudy"`; the post-fit successor `results.aggregate('event_study')` returns a unified `EventStudyResults` container instead of populating this field) | +| `event_study_df` | `float | None` | Scalar survey df governing the ES rows' t-inference (3.12, M-092 completion); `None` on non-survey fits, on bootstrapped fits, when no fit-time ES surface was built, and for the replicate-undefined `0` sentinel | **DoseResponseCurve** sub-dataclass: