diff-diff 3.9.0__tar.gz → 3.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. {diff_diff-3.9.0 → diff_diff-3.10.0}/PKG-INFO +4 -2
  2. {diff_diff-3.9.0 → diff_diff-3.10.0}/README.md +2 -0
  3. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/__init__.py +15 -1
  4. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/_reporting_helpers.py +9 -6
  5. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/_staggered_triple_diff_engine.py +11 -4
  6. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/aggregation.py +120 -10
  7. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/bootstrap_chunking.py +17 -0
  8. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/bootstrap_utils.py +113 -6
  9. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/business_report.py +23 -5
  10. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/continuous_did.py +18 -3
  11. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/datasets.py +5 -1
  12. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/diagnostic_report.py +573 -113
  13. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/efficient_did.py +78 -57
  14. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/efficient_did_aggregation.py +36 -7
  15. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/efficient_did_bootstrap.py +99 -15
  16. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/efficient_did_results.py +169 -24
  17. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/estimators.py +16 -4
  18. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/guides/llms-autonomous.txt +46 -4
  19. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/guides/llms-full.txt +256 -47
  20. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/guides/llms-practitioner.txt +61 -12
  21. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/guides/llms.txt +4 -2
  22. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/imputation.py +29 -6
  23. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/imputation_results.py +55 -5
  24. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/linalg.py +84 -25
  25. diff_diff-3.10.0/diff_diff/lwdid.py +4503 -0
  26. diff_diff-3.10.0/diff_diff/lwdid_randomization.py +508 -0
  27. diff_diff-3.10.0/diff_diff/lwdid_results.py +826 -0
  28. diff_diff-3.10.0/diff_diff/lwdid_sensitivity.py +907 -0
  29. diff_diff-3.10.0/diff_diff/lwdid_staggered.py +817 -0
  30. diff_diff-3.10.0/diff_diff/lwdid_visualization.py +303 -0
  31. diff_diff-3.10.0/diff_diff/lwdid_wild_bootstrap.py +360 -0
  32. diff_diff-3.10.0/diff_diff/mmm.py +1600 -0
  33. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/practitioner.py +32 -28
  34. diff_diff-3.10.0/diff_diff/rddensity.py +1417 -0
  35. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/results.py +2 -0
  36. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/results_base.py +3 -2
  37. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/spillover.py +45 -16
  38. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/stacked_did.py +17 -3
  39. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/staggered.py +94 -86
  40. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/staggered_bootstrap.py +126 -9
  41. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/staggered_results.py +269 -31
  42. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/staggered_triple_diff.py +3 -1
  43. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/sun_abraham.py +9 -1
  44. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/triple_diff.py +7 -5
  45. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/twfe.py +14 -14
  46. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/two_stage.py +10 -4
  47. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/two_stage_results.py +55 -7
  48. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/utils.py +17 -5
  49. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/wooldridge.py +14 -6
  50. {diff_diff-3.9.0 → diff_diff-3.10.0}/pyproject.toml +2 -2
  51. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/Cargo.lock +19 -19
  52. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/Cargo.toml +1 -1
  53. diff_diff-3.9.0/diff_diff/mmm.py +0 -653
  54. {diff_diff-3.9.0 → diff_diff-3.10.0}/LICENSE +0 -0
  55. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/_backend.py +0 -0
  56. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/_base.py +0 -0
  57. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/_deprecation.py +0 -0
  58. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/_guides_api.py +0 -0
  59. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/_nprobust_port.py +0 -0
  60. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/_rdrobust_port.py +0 -0
  61. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/agent_workflow.py +0 -0
  62. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/bacon.py +0 -0
  63. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/balancing.py +0 -0
  64. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/chaisemartin_dhaultfoeuille.py +0 -0
  65. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
  66. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
  67. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/changes_in_changes.py +0 -0
  68. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/changes_in_changes_results.py +0 -0
  69. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/conformal.py +0 -0
  70. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/conley.py +0 -0
  71. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/continuous_did_aggregation.py +0 -0
  72. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/continuous_did_bspline.py +0 -0
  73. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/continuous_did_results.py +0 -0
  74. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/diagnostics.py +0 -0
  75. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/efficient_did_covariates.py +0 -0
  76. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/efficient_did_weights.py +0 -0
  77. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/guides/__init__.py +0 -0
  78. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/had.py +0 -0
  79. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/had_pretests.py +0 -0
  80. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/honest_did.py +0 -0
  81. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/imputation_aggregation.py +0 -0
  82. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/imputation_bootstrap.py +0 -0
  83. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/local_linear.py +0 -0
  84. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/lpdid.py +0 -0
  85. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/lpdid_results.py +0 -0
  86. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/power.py +0 -0
  87. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/prep.py +0 -0
  88. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/prep_dgp.py +0 -0
  89. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/pretrends.py +0 -0
  90. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/profile.py +0 -0
  91. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/rdd.py +0 -0
  92. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/rdplot.py +0 -0
  93. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/stacked_did_results.py +0 -0
  94. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/staggered_aggregation.py +0 -0
  95. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/staggered_triple_diff_results.py +0 -0
  96. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/survey.py +0 -0
  97. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/synthetic_control.py +0 -0
  98. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/synthetic_control_results.py +0 -0
  99. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/synthetic_did.py +0 -0
  100. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/trop.py +0 -0
  101. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/trop_global.py +0 -0
  102. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/trop_local.py +0 -0
  103. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/trop_results.py +0 -0
  104. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/two_stage_aggregation.py +0 -0
  105. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/two_stage_bootstrap.py +0 -0
  106. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/visualization/__init__.py +0 -0
  107. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/visualization/_common.py +0 -0
  108. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/visualization/_continuous.py +0 -0
  109. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/visualization/_diagnostic.py +0 -0
  110. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/visualization/_event_study.py +0 -0
  111. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/visualization/_power.py +0 -0
  112. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/visualization/_staggered.py +0 -0
  113. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/visualization/_synthetic.py +0 -0
  114. {diff_diff-3.9.0 → diff_diff-3.10.0}/diff_diff/wooldridge_results.py +0 -0
  115. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/build.rs +0 -0
  116. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/src/alloc_profile.rs +0 -0
  117. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/src/batched_solve.rs +0 -0
  118. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/src/bootstrap.rs +0 -0
  119. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/src/demean.rs +0 -0
  120. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/src/lib.rs +0 -0
  121. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/src/linalg.rs +0 -0
  122. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/src/trop.rs +0 -0
  123. {diff_diff-3.9.0 → diff_diff-3.10.0}/rust/src/weights.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 3.9.0
3
+ Version: 3.10.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: OS Independent
@@ -21,7 +21,7 @@ Requires-Dist: pytest>=7.0 ; extra == 'dev'
21
21
  Requires-Dist: pytest-xdist>=3.0 ; extra == 'dev'
22
22
  Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
23
23
  Requires-Dist: black==26.5.1 ; extra == 'dev'
24
- Requires-Dist: ruff==0.16.0 ; extra == 'dev'
24
+ Requires-Dist: ruff==0.16.3 ; extra == 'dev'
25
25
  Requires-Dist: mypy==2.3.0 ; extra == 'dev'
26
26
  Requires-Dist: maturin>=1.4,<2.0 ; extra == 'dev'
27
27
  Requires-Dist: matplotlib>=3.5 ; extra == 'dev'
@@ -175,11 +175,13 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
175
175
  - [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html) - Wooldridge (2023, 2025) ETWFE: saturated OLS, logit/Poisson QMLE (ASF-based ATT). Alias `ETWFE`.
176
176
  - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment
177
177
  - [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator via `method="qdid"`; bootstrap inference; R qte parity. Alias `CiC`
178
+ - [LWDiD](https://diff-diff.readthedocs.io/en/stable/api/lwdid.html) - Lee & Wooldridge (2025, 2026) rolling-transformation DiD: unit-specific demean/detrend converts panel to cross-section, staggered adoption, `estimation_method` in `reg`/`ipw`/`dr`/`psm` (the papers' RA/IPW/IPWRA plus propensity-score matching), exact small-N inference on the classical collapsed regression
178
179
  - [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings
179
180
 
180
181
  ## Diagnostics & Sensitivity
181
182
 
182
183
  - [RD Plots](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2015) optimal data-driven RD plots (`RDPlot`): all 8 rdrobust `binselect` bin selectors, implied-scale/WIMSE-weight reporting, optional matplotlib rendering
184
+ - [Manipulation Testing](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Cattaneo, Jansson & Ma (2020) density-discontinuity test (`RDDensityTest`): rddensity 3.0 parity, robust bias-corrected inference, unrestricted/restricted models, mass-point adjustment
183
185
  - [Parallel Trends Testing](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html) - simple and Wasserstein-robust parallel trends tests, equivalence testing (TOST)
184
186
  - [Placebo Tests](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html) - placebo timing, group, permutation, leave-one-out
185
187
  - [Honest DiD](https://diff-diff.readthedocs.io/en/stable/api/honest_did.html) - Rambachan & Roth (2023) sensitivity analysis: robust CI under PT violations, breakdown values
@@ -120,11 +120,13 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
120
120
  - [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html) - Wooldridge (2023, 2025) ETWFE: saturated OLS, logit/Poisson QMLE (ASF-based ATT). Alias `ETWFE`.
121
121
  - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment
122
122
  - [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator via `method="qdid"`; bootstrap inference; R qte parity. Alias `CiC`
123
+ - [LWDiD](https://diff-diff.readthedocs.io/en/stable/api/lwdid.html) - Lee & Wooldridge (2025, 2026) rolling-transformation DiD: unit-specific demean/detrend converts panel to cross-section, staggered adoption, `estimation_method` in `reg`/`ipw`/`dr`/`psm` (the papers' RA/IPW/IPWRA plus propensity-score matching), exact small-N inference on the classical collapsed regression
123
124
  - [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings
124
125
 
125
126
  ## Diagnostics & Sensitivity
126
127
 
127
128
  - [RD Plots](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2015) optimal data-driven RD plots (`RDPlot`): all 8 rdrobust `binselect` bin selectors, implied-scale/WIMSE-weight reporting, optional matplotlib rendering
129
+ - [Manipulation Testing](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Cattaneo, Jansson & Ma (2020) density-discontinuity test (`RDDensityTest`): rddensity 3.0 parity, robust bias-corrected inference, unrestricted/restricted models, mass-point adjustment
128
130
  - [Parallel Trends Testing](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html) - simple and Wasserstein-robust parallel trends tests, equivalence testing (TOST)
129
131
  - [Placebo Tests](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html) - placebo timing, group, permutation, leave-one-out
130
132
  - [Honest DiD](https://diff-diff.readthedocs.io/en/stable/api/honest_did.html) - Rambachan & Roth (2023) sensitivity analysis: robust CI under PT violations, breakdown values
@@ -160,8 +160,11 @@ from diff_diff.local_linear import (
160
160
  )
161
161
  from diff_diff.lpdid import LPDiD
162
162
  from diff_diff.lpdid_results import LPDiDResults
163
+ from diff_diff.lwdid import LWDiD
164
+ from diff_diff.lwdid_results import LWDiDResults
163
165
  from diff_diff.mmm import (
164
166
  MeridianROIPrior,
167
+ meridian_calibration_mask,
165
168
  to_meridian_roi_prior,
166
169
  to_pymc_marketing_lift_test,
167
170
  )
@@ -223,6 +226,10 @@ from diff_diff.rdd import (
223
226
  RegressionDiscontinuity,
224
227
  RegressionDiscontinuityResults,
225
228
  )
229
+ from diff_diff.rddensity import (
230
+ RDDensityTest,
231
+ RDDensityTestResult,
232
+ )
226
233
  from diff_diff.rdplot import (
227
234
  RDPlot,
228
235
  RDPlotResult,
@@ -361,7 +368,7 @@ def __getattr__(name: str) -> _Any:
361
368
  raise AttributeError(f"module 'diff_diff' has no attribute {name!r}")
362
369
 
363
370
 
364
- __version__ = "3.9.0"
371
+ __version__ = "3.10.0"
365
372
  __all__ = [
366
373
  # Estimators
367
374
  "DifferenceInDifferences",
@@ -454,6 +461,9 @@ __all__ = [
454
461
  # LPDiD (Local Projections DiD)
455
462
  "LPDiD",
456
463
  "LPDiDResults",
464
+ # LWDiD (Lee & Wooldridge rolling transformation DiD)
465
+ "LWDiD",
466
+ "LWDiDResults",
457
467
  # Visualization
458
468
  "plot_bacon",
459
469
  "plot_event_study",
@@ -570,6 +580,9 @@ __all__ = [
570
580
  # RDPlot (data-driven RD plots, rdplot parity)
571
581
  "RDPlot",
572
582
  "RDPlotResult",
583
+ # RDDensityTest (manipulation testing, rddensity parity)
584
+ "RDDensityTest",
585
+ "RDDensityTestResult",
573
586
  # HeterogeneousAdoptionDiD pre-test diagnostics (Phase 3)
574
587
  "qug_test",
575
588
  "stute_test",
@@ -613,6 +626,7 @@ __all__ = [
613
626
  # MMM calibration export (interop)
614
627
  "to_pymc_marketing_lift_test",
615
628
  "to_meridian_roi_prior",
629
+ "meridian_calibration_mask",
616
630
  "MeridianROIPrior",
617
631
  # LLM guide accessor
618
632
  "get_llm_guide",
@@ -57,9 +57,10 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
57
57
  the horizon / group target.
58
58
  - ``CallawaySantAnna``: ``overall_att`` is cohort-size-weighted
59
59
  across post-treatment ``ATT(g, t)`` cells regardless of the
60
- fit-time ``aggregate`` kwarg. The event-study / group
61
- aggregations live on dedicated fields
62
- (``event_study_effects`` / ``group_effects``).
60
+ fit-time ``aggregate`` kwarg. The event-study / group tables are
61
+ produced post-fit via ``results.aggregate('event_study'/'group')``
62
+ (the deprecated fit-time ``aggregate=`` kwarg populates the legacy
63
+ ``event_study_effects`` / ``group_effects`` fields until 4.0).
63
64
  - ``ContinuousDiD``: the regime (PT vs. SPT) is a user-level
64
65
  assumption, not a library setting. The ``definition`` names
65
66
  both regime readings (``ATT^loc`` under PT,
@@ -137,9 +138,11 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
137
138
  "A cohort-size-weighted average of group-time ATTs "
138
139
  "``ATT(g, t)`` across post-treatment cells (``t >= g``). "
139
140
  "``overall_att`` is the simple-aggregation headline regardless "
140
- "of the fit-time ``aggregate`` kwarg; event-study and group "
141
- "aggregations populate ``event_study_effects`` / "
142
- "``group_effects`` fields when requested."
141
+ "of aggregation choices; event-study and group tables are "
142
+ "produced post-fit via "
143
+ "``results.aggregate('event_study'/'group')`` (the deprecated "
144
+ "fit-time ``aggregate=`` kwarg populates the legacy "
145
+ "``event_study_effects`` / ``group_effects`` fields until 4.0)."
143
146
  ),
144
147
  "aggregation": "simple",
145
148
  "headline_attribute": "overall_att",
@@ -64,10 +64,13 @@ class _StaggeredTripleDiffEngineMixin:
64
64
  supply the constructor attributes and the CS aggregation/bootstrap mixins
65
65
  this core calls. The annotations below exist because mypy type-checks this
66
66
  class independently of its hosts (`attr-defined` is not disabled) - they are
67
- declarations, never assignments.
67
+ declarations, never assignments — with ONE exception: `anticipation` is
68
+ re-assigned by the core's fit-time re-validation (the mutation-defense
69
+ re-check normalizes it to a Python int; see `_fit_staggered_core`).
68
70
  """
69
71
 
70
- # Constructor attributes read from the host class.
72
+ # Constructor attributes read from the host class. (`anticipation` is
73
+ # additionally RE-ASSIGNED at fit — the validate-and-normalize re-check.)
71
74
  estimation_method: str
72
75
  control_group: str
73
76
  alpha: float
@@ -142,6 +145,8 @@ class _StaggeredTripleDiffEngineMixin:
142
145
  unit: Optional[str] = None,
143
146
  precomputed: Any = None,
144
147
  cband: bool = True,
148
+ *,
149
+ _replay_bitgen_state: Optional[Dict[str, Any]] = None,
145
150
  ) -> Any: ...
146
151
 
147
152
  def _fit_staggered_core(
@@ -178,8 +183,10 @@ class _StaggeredTripleDiffEngineMixin:
178
183
  # the deprecated StaggeredTripleDifference (whose 3.x API SHAPE is
179
184
  # frozen through removal - that freeze was never a licence to emit
180
185
  # silently-biased numbers) and direct attribute mutation on either
181
- # class, which bypasses __init__ and set_params alike.
182
- validate_anticipation(self.anticipation)
186
+ # class, which bypasses __init__ and set_params alike. The
187
+ # assignment form also normalizes a numpy scalar to a Python int
188
+ # before any `g - 1 - anticipation` arithmetic can overflow.
189
+ self.anticipation = validate_anticipation(self.anticipation)
183
190
  from diff_diff.survey import (
184
191
  _resolve_survey_for_fit,
185
192
  _validate_unit_constant_survey,
@@ -45,6 +45,13 @@ AGGREGATION_VOCABULARY: Tuple[str, ...] = (
45
45
  "event_study",
46
46
  "group",
47
47
  "calendar",
48
+ # "total": the estimator-owned total incremental outcome - an exact relay
49
+ # C x overall (C = the estimator's finite-masked complete-case aggregation
50
+ # support), CONDITIONAL on the realized aggregation mass. Shared meaning
51
+ # across adopters (CallawaySantAnna / ImputationDiD / TwoStageDiD /
52
+ # EfficientDiD); per-estimator support remains a subset, so non-adopters
53
+ # raise the same vocabulary-suffixed error as "calendar".
54
+ "total",
48
55
  )
49
56
 
50
57
  #: Pinned column schema of ``AggregationResult.to_dataframe()`` - identical for
@@ -97,6 +104,77 @@ def resolve_inference_df(results: Any) -> Optional[float]:
97
104
  return None
98
105
 
99
106
 
107
+ def build_total_relay_row(
108
+ *,
109
+ mass: float,
110
+ att: Optional[float],
111
+ se: Optional[float],
112
+ t_stat: Optional[float],
113
+ p_value: Optional[float],
114
+ conf_int: Tuple[float, float],
115
+ df: Optional[float],
116
+ alpha: float,
117
+ estimator: str,
118
+ ) -> "AggregationResult":
119
+ """One ``level='total'`` row: the exact relay ``mass x overall``.
120
+
121
+ The estimand is the total incremental outcome CONDITIONAL ON THE REALIZED
122
+ AGGREGATION MASS: ``att = mass x overall_att``, ``se = mass x overall_se``,
123
+ CI scaled by ``mass``, ``t_stat``/``p_value``/``df`` inherited unchanged
124
+ (every SE branch is homogeneous of degree 1, so the relay is exact under
125
+ analytical AND percentile-bootstrap inference). Inherited NaNs PASS
126
+ THROUGH - a degenerate fit whose CI is (nan, nan) beside a finite att
127
+ keeps its finite ``att``/``n``, mirroring what ``aggregate('simple')``
128
+ publishes on the same fit (the repo's non-estimable-row convention:
129
+ att/n are inputs, never blanked by NaN inference fields).
130
+
131
+ Two cases blank the WHOLE row (att/se/t/p/CI/n/df all NaN):
132
+ - ``mass`` is not finite (no aggregable support exists), or
133
+ - a FINITE overall value became non-finite BY the scaling (true float
134
+ overflow; surfaced per this code family's finiteness convention rather
135
+ than an ``errstate`` wrap).
136
+ """
137
+
138
+ def _f(v: Optional[float]) -> float:
139
+ return float(v) if v is not None else np.nan
140
+
141
+ a, s = _f(att), _f(se)
142
+ t, p = _f(t_stat), _f(p_value)
143
+ lo, hi = _f(conf_int[0]), _f(conf_int[1])
144
+ d = _f(df)
145
+
146
+ s_att, s_se = mass * a, mass * s
147
+ s_lo, s_hi = mass * lo, mass * hi
148
+ n_val = float(mass)
149
+ overflowed = np.isfinite(mass) and (
150
+ (np.isfinite(a) and not np.isfinite(s_att))
151
+ or (np.isfinite(s) and not np.isfinite(s_se))
152
+ or (np.isfinite(lo) and not np.isfinite(s_lo))
153
+ or (np.isfinite(hi) and not np.isfinite(s_hi))
154
+ )
155
+ if not np.isfinite(mass) or overflowed:
156
+ s_att = s_se = s_lo = s_hi = t = p = d = np.nan
157
+ n_val = np.nan
158
+
159
+ return AggregationResult(
160
+ level="total",
161
+ label=np.array(["total"], dtype=object),
162
+ target=np.array(["total"], dtype=object),
163
+ att=np.array([s_att], dtype=float),
164
+ se=np.array([s_se], dtype=float),
165
+ t_stat=np.array([t], dtype=float),
166
+ p_value=np.array([p], dtype=float),
167
+ conf_int_lower=np.array([s_lo], dtype=float),
168
+ conf_int_upper=np.array([s_hi], dtype=float),
169
+ n=np.array([n_val], dtype=float),
170
+ df=d,
171
+ alpha=alpha,
172
+ n_kind="obs",
173
+ weight=np.array([1.0], dtype=float),
174
+ estimator=estimator,
175
+ )
176
+
177
+
100
178
  def _sortable(labels: np.ndarray) -> bool:
101
179
  """Can ``labels`` be ordered without raising?
102
180
 
@@ -128,11 +206,14 @@ class AggregationResult(BaseResults):
128
206
  label : np.ndarray
129
207
  Per-row aggregation key: the cohort for ``"group"``, the calendar
130
208
  period for ``"calendar"``, the dose for ``"dose"``. A single
131
- ``"overall"`` entry for ``"simple"``.
209
+ ``"overall"`` entry for ``"simple"``; a single ``"total"`` entry for
210
+ ``"total"``.
132
211
  target : np.ndarray
133
212
  Per-row estimand discriminator, so one container can carry two
134
213
  aligned estimands over the same labels (ContinuousDiD's ATT(d) and
135
- ACRT(d) become 2N rows). ``"att"`` where an estimator has one.
214
+ ACRT(d) become 2N rows). ``"att"`` where an estimator has one;
215
+ ``"total"`` on a ``"total"`` row - a total incremental outcome over
216
+ the estimator's aggregation mass, not an ATT.
136
217
  att, se, t_stat, p_value : np.ndarray
137
218
  The canonical quintet, per row, carrying WHATEVER inference the fit
138
219
  stored - never recomputed. On a bootstrapped fit that usually means
@@ -157,7 +238,8 @@ class AggregationResult(BaseResults):
157
238
  ``(level, target)`` group. ``None`` where no per-row mass exists -
158
239
  CallawaySantAnna's ``"group"`` aggregation weights ``(g, t)`` cells
159
240
  equally WITHIN each cohort and has no cross-cohort mass, so inventing
160
- one would be a fabricated number.
241
+ one would be a fabricated number. Single-row containers
242
+ (``"simple"``, ``"total"``) carry ``weight=[1.0]``.
161
243
  df : np.ndarray
162
244
  Per-row inference degrees of freedom, NaN where none governed the
163
245
  stored p-value. NaN on percentile-bootstrap rows (no df governs
@@ -454,9 +536,12 @@ class AggregationKit:
454
536
  ----------
455
537
  bookkeeping : dict
456
538
  The aggregation-relevant subset of the estimator's ``precomputed``
457
- mapping. O(n_units) on panel fits; several entries are
458
- observation-length on repeated cross-sections, where
459
- ``all_units = np.arange(n_obs)`` by construction.
539
+ mapping, plus fit-time DERIVED keys stashed by the kit builder
540
+ (CallawaySantAnna's ``agg_gt_cells``/``is_survey_fit``,
541
+ ImputationDiD's ``total_support`` - see each builder). O(n_units) on
542
+ panel fits, except ``agg_gt_cells`` which is O(n_gt) 4-tuples;
543
+ several entries are observation-length on repeated cross-sections,
544
+ where ``all_units = np.arange(n_obs)`` by construction.
460
545
  influence : dict
461
546
  Per-``(g, t)`` influence-function payload. The DOMINANT retained
462
547
  object, roughly O(n_units x n_gt).
@@ -468,7 +553,11 @@ class AggregationKit:
468
553
  ``cband_crit_value`` is ``None`` both when bands were disabled and
469
554
  when no aggregation ran, so it cannot distinguish the two.
470
555
  bootstrap : AggregationKit.BootstrapReplaySpec or None
471
- Value-bound bootstrap replay description; ``None`` on analytical fits.
556
+ Value-bound bootstrap replay description. Populated on
557
+ CallawaySantAnna and EfficientDiD bootstrapped fits (their recompute
558
+ levels replay the fit-time multiplier bootstrap from it); ``None``
559
+ on analytical fits and on pre-replay legacy artifacts (whose
560
+ bootstrapped recompute levels fail closed with a refit message).
472
561
  """
473
562
 
474
563
  bookkeeping: Dict[str, Any]
@@ -489,9 +578,29 @@ class BootstrapReplaySpec:
489
578
  ``set_params(n_bootstrap=...)`` silently changes - and can truncate - the
490
579
  replayed stream.
491
580
 
492
- This records the generator state plus the parameters BY VALUE and rebuilds
493
- the stream through a module-level factory, which replays bit-identically,
494
- pickles, and is immune to later mutation of the estimator.
581
+ This records the generator state plus the parameters BY VALUE, which
582
+ pickles and is immune to later mutation of the estimator. Two usage
583
+ modes:
584
+
585
+ - ``rebuild()`` reconstructs the plain unit-level stream via
586
+ ``iter_weight_blocks`` (it does NOT cover the survey/FPC/PSU-expansion
587
+ branches).
588
+ - The CallawaySantAnna and EfficientDiD post-fit replays are
589
+ STATE-ONLY: they consume
590
+ ``bitgen_state``/``n_bootstrap``/``weight_type``/``backend`` and let
591
+ each engine's ``_run_multiplier_bootstrap`` re-derive the generation
592
+ branch from the kit bookkeeping - one branch-selection implementation
593
+ per engine, no drift.
594
+
595
+ ``backend`` records the weight-generation backend identity at capture
596
+ (``"rust"``/``"numpy"`` per
597
+ :func:`diff_diff.bootstrap_chunking.effective_weight_backend`, or
598
+ ``"portable"`` for provably backend-independent generation branches).
599
+ The Rust and NumPy generators produce DIFFERENT draws from the same
600
+ bit-generator state, so a replay under a different backend must FAIL
601
+ CLOSED rather than silently regenerate another realization. ``None``
602
+ means unknown and also fails closed - a permissive default on a safety
603
+ discriminator would let a future constructor silently bypass the guard.
495
604
  """
496
605
 
497
606
  bitgen_state: Dict[str, Any]
@@ -500,6 +609,7 @@ class BootstrapReplaySpec:
500
609
  weight_type: str
501
610
  block_size: Optional[int] = None
502
611
  expand_index: Optional[np.ndarray] = None
612
+ backend: Optional[str] = None
503
613
 
504
614
  def rebuild(self) -> Any:
505
615
  """Reconstruct the replayable weight stream."""
@@ -70,6 +70,23 @@ if TYPE_CHECKING:
70
70
  _TARGET_BLOCK_BYTES = 256 * 1024 * 1024
71
71
 
72
72
 
73
+ def effective_weight_backend() -> str:
74
+ """The weight-generation backend :func:`iter_weight_blocks` would use NOW.
75
+
76
+ Returns ``"rust"`` exactly when the generator branch below does — the
77
+ predicate must stay identical to :func:`iter_weight_blocks`'s own
78
+ ``rust_gen`` resolution. The two backends produce DIFFERENT draws from
79
+ the same bit-generator state (Rust draws one base seed and row-seeds
80
+ Xoshiro absolutely; the NumPy fallback consumes the PCG64 stream
81
+ directly), so a captured RNG state replays bit-identically only within
82
+ one backend. Post-fit bootstrap replay (the CallawaySantAnna and
83
+ EfficientDiD ``BootstrapReplaySpec``) stamps this value at fit and fails
84
+ closed on a mismatch rather than silently regenerating a different
85
+ realization.
86
+ """
87
+ return "rust" if (HAS_RUST_BACKEND and _rust_bootstrap_weights is not None) else "numpy"
88
+
89
+
73
90
  def compute_block_size(
74
91
  n_units: int, n_bootstrap: int, target_bytes: int = _TARGET_BLOCK_BYTES
75
92
  ) -> int:
@@ -1,16 +1,18 @@
1
1
  """
2
2
  Shared bootstrap utilities for multiplier bootstrap inference.
3
3
 
4
- Provides weight generation, percentile CI, and p-value helpers used by
5
- both CallawaySantAnna and ContinuousDiD estimators.
4
+ Provides weight generation, percentile statistics (CI / p-value / per-effect
5
+ stats), and the percentile-override appliers shared across the estimator
6
+ bootstrap engines.
6
7
  """
7
8
 
8
9
  import warnings
9
- from typing import TYPE_CHECKING, Optional, Tuple
10
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Protocol, Tuple
10
11
 
11
12
  import numpy as np
12
13
 
13
14
  from diff_diff._backend import HAS_RUST_BACKEND, _rust_bootstrap_weights
15
+ from diff_diff.utils import safe_inference_batch
14
16
 
15
17
  if TYPE_CHECKING:
16
18
  from diff_diff.survey import ResolvedSurveyDesign
@@ -29,6 +31,8 @@ __all__ = [
29
31
  "compute_effect_bootstrap_stats_batch",
30
32
  "warn_bootstrap_failure_rate",
31
33
  "stratified_bootstrap_indices",
34
+ "apply_bootstrap_event_study_overrides",
35
+ "apply_bootstrap_group_overrides",
32
36
  ]
33
37
 
34
38
 
@@ -366,7 +370,12 @@ def compute_effect_bootstrap_stats(
366
370
  se = float(np.std(valid_dist, ddof=1))
367
371
 
368
372
  # Guard: if SE is not finite or zero, all inference fields must be NaN.
369
- if not np.isfinite(se) or se <= 0:
373
+ # An EXACTLY CONSTANT distribution is degenerate too, even when its
374
+ # np.std comes back tiny-positive from mean-subtraction roundoff at a
375
+ # non-zero constant level (e.g. census-FPC zero-weight draws leave every
376
+ # replicate at the original effect): a t built on that roundoff SE would
377
+ # be astronomically large and silently "significant".
378
+ if not np.isfinite(se) or se <= 0 or float(valid_dist.max()) == float(valid_dist.min()):
370
379
  warnings.warn(
371
380
  f"Bootstrap SE is non-finite or zero (n_valid={n_valid}) in {context}. "
372
381
  "Returning NaN for SE/CI/p-value.",
@@ -472,8 +481,13 @@ def compute_effect_bootstrap_stats_batch(
472
481
  batch_p = np.minimum(2 * batch_p, 1.0)
473
482
  batch_p = np.maximum(batch_p, 1 / (n_bootstrap + 1))
474
483
 
475
- # Guard: SE must be positive and finite
476
- se_valid = np.isfinite(batch_ses) & (batch_ses > 0)
484
+ # Guard: SE must be positive and finite, and the distribution must
485
+ # not be EXACTLY CONSTANT (a constant non-zero level can produce a
486
+ # tiny-positive np.std from mean-subtraction roundoff - e.g.
487
+ # census-FPC zero-weight draws - which would otherwise leak a
488
+ # roundoff SE and an astronomically large t past the zero check).
489
+ is_constant = sub.max(axis=0) == sub.min(axis=0)
490
+ se_valid = np.isfinite(batch_ses) & (batch_ses > 0) & ~is_constant
477
491
  n_bad_se = int(np.sum(~se_valid))
478
492
  if n_bad_se > 0:
479
493
  warnings.warn(
@@ -948,3 +962,96 @@ def generate_rao_wu_weights_batch(
948
962
  for b in range(n_bootstrap):
949
963
  result[b] = generate_rao_wu_weights(resolved_survey, rng)
950
964
  return result
965
+
966
+
967
+ # =============================================================================
968
+ # Bootstrap override helpers (shared by fit and the post-fit replay)
969
+ # =============================================================================
970
+ # Extracted verbatim from CallawaySantAnna.fit()'s inline blocks (and adopted
971
+ # by EfficientDiD's fit/replay) so the post-fit aggregate() replay applies
972
+ # EXACTLY the same percentile overrides the fit-time path applies — one
973
+ # implementation, no twin drift. (The deprecated StaggeredTripleDifference
974
+ # keeps its OWN copy of the group replacement loop; unifying it is sequenced
975
+ # with the M-014 container port.) Note on warning attribution: when the
976
+ # engines run under the post-fit replay their fit-tuned stacklevels resolve
977
+ # into library frames rather than the user's aggregate() call — accepted as
978
+ # cosmetic (recorded decision).
979
+
980
+
981
+ class _BootstrapOverrideSource(Protocol):
982
+ """Structural contract for bootstrap containers the appliers consume.
983
+
984
+ Both ``CSBootstrapResults`` and ``EDiDBootstrapResults`` satisfy it by
985
+ field name; a Protocol keeps this module free of estimator imports.
986
+ """
987
+
988
+ event_study_ses: Optional[Dict[Any, float]]
989
+ event_study_cis: Optional[Dict[Any, Tuple[float, float]]]
990
+ event_study_p_values: Optional[Dict[Any, float]]
991
+ group_effect_ses: Optional[Dict[Any, float]]
992
+ group_effect_cis: Optional[Dict[Any, Tuple[float, float]]]
993
+ group_effect_p_values: Optional[Dict[Any, float]]
994
+
995
+
996
+ def apply_bootstrap_event_study_overrides(
997
+ event_study_effects: Optional[Dict[int, Dict[str, Any]]],
998
+ bootstrap_results: _BootstrapOverrideSource,
999
+ alpha: float,
1000
+ ) -> None:
1001
+ """Overwrite per-event-time se/CI/p with percentile-bootstrap values.
1002
+
1003
+ Mutates ``event_study_effects`` in place; t is recomputed from the
1004
+ percentile SE via ``safe_inference_batch``. No-op when either side has
1005
+ no event-study surface.
1006
+ """
1007
+ if (
1008
+ event_study_effects is not None
1009
+ and bootstrap_results.event_study_ses is not None
1010
+ and bootstrap_results.event_study_cis is not None
1011
+ and bootstrap_results.event_study_p_values is not None
1012
+ ):
1013
+ es_keys = [e for e in event_study_effects if e in bootstrap_results.event_study_ses]
1014
+ if es_keys:
1015
+ es_effects_arr = np.array([float(event_study_effects[e]["effect"]) for e in es_keys])
1016
+ es_ses_arr = np.array([float(bootstrap_results.event_study_ses[e]) for e in es_keys])
1017
+ es_t_stats, _, _, _ = safe_inference_batch(es_effects_arr, es_ses_arr, alpha=alpha)
1018
+ for idx, e in enumerate(es_keys):
1019
+ event_study_effects[e]["se"] = bootstrap_results.event_study_ses[e]
1020
+ event_study_effects[e]["conf_int"] = bootstrap_results.event_study_cis[e]
1021
+ event_study_effects[e]["p_value"] = bootstrap_results.event_study_p_values[e]
1022
+ event_study_effects[e]["t_stat"] = float(es_t_stats[idx])
1023
+
1024
+
1025
+ def apply_bootstrap_group_overrides(
1026
+ group_effects: Optional[Dict[Any, Dict[str, Any]]],
1027
+ bootstrap_results: _BootstrapOverrideSource,
1028
+ alpha: float,
1029
+ ) -> None:
1030
+ """Overwrite per-group se/CI/p with percentile-bootstrap values.
1031
+
1032
+ Mutates ``group_effects`` in place and clears each row's ``df_used``
1033
+ (the percentile inference never used the analytical df, so keeping it
1034
+ would claim a t-reference that governed nothing). No-op when either
1035
+ side has no group surface.
1036
+ """
1037
+ if (
1038
+ group_effects is not None
1039
+ and bootstrap_results.group_effect_ses is not None
1040
+ and bootstrap_results.group_effect_cis is not None
1041
+ and bootstrap_results.group_effect_p_values is not None
1042
+ ):
1043
+ grp_keys = [g for g in group_effects if g in bootstrap_results.group_effect_ses]
1044
+ if grp_keys:
1045
+ grp_effects_arr = np.array([float(group_effects[g]["effect"]) for g in grp_keys])
1046
+ grp_ses_arr = np.array([float(bootstrap_results.group_effect_ses[g]) for g in grp_keys])
1047
+ grp_t_stats, _, _, _ = safe_inference_batch(grp_effects_arr, grp_ses_arr, alpha=alpha)
1048
+ for idx, g in enumerate(grp_keys):
1049
+ group_effects[g]["se"] = bootstrap_results.group_effect_ses[g]
1050
+ group_effects[g]["conf_int"] = bootstrap_results.group_effect_cis[g]
1051
+ group_effects[g]["p_value"] = bootstrap_results.group_effect_p_values[g]
1052
+ group_effects[g]["t_stat"] = float(grp_t_stats[idx])
1053
+ # Same clearing rule the ES df provenance follows: these
1054
+ # se/p/CI are now percentile-bootstrap values that never used
1055
+ # the analytical df, so keeping df_used would claim a
1056
+ # t-reference that governed nothing.
1057
+ group_effects[g]["df_used"] = None
@@ -13,9 +13,13 @@ Design principles:
13
13
 
14
14
  - Plain English, not academic jargon. The library ships this in addition to, not
15
15
  in place of, the estimator's existing ``results.summary()`` academic output.
16
- - No estimator fitting and no variance re-derivation. Every effect, SE, p-value,
17
- CI, and sensitivity bound is either read from ``results`` or produced by an
18
- existing diff-diff utility. The report layer does compose a few cross-period
16
+ - No estimator fitting. Every effect, SE, p-value, CI, and sensitivity bound
17
+ is either read from ``results``, derived by the auto-constructed
18
+ ``DiagnosticReport`` from the result's own post-fit
19
+ ``aggregate('event_study')`` surface (a view or retained-kit recompute,
20
+ used only when the raw ``event_study_effects`` field is absent; see the
21
+ ``DiagnosticReport`` module docstring), or produced by an existing
22
+ diff-diff utility. The report layer does compose a few cross-period
19
23
  summaries from per-period inputs already on the result (joint-Wald / Bonferroni
20
24
  pre-trends p-value, MDV-to-ATT ratio, heterogeneity dispersion over
21
25
  post-treatment effects); see ``docs/methodology/REPORTING.md`` for the full
@@ -351,8 +355,10 @@ class BusinessReport:
351
355
  "precomputed= contains keys that are not implemented: "
352
356
  f"{sorted(_br_unsupported)}. Supported keys: "
353
357
  f"{sorted(_br_supported_precomputed)}. ``design_effect``, "
354
- "``heterogeneity``, and ``epv`` are read directly from the "
355
- "fitted result and do not accept precomputed overrides."
358
+ "``heterogeneity``, and ``epv`` are read from the fitted "
359
+ "result (heterogeneity may also derive the post-fit "
360
+ "aggregate('event_study') surface) and do not accept "
361
+ "precomputed overrides."
356
362
  )
357
363
 
358
364
  resolved_alpha = alpha if alpha is not None else getattr(results, "alpha", 0.05)
@@ -941,6 +947,10 @@ def _lift_pre_trends(dr: Optional[Dict[str, Any]]) -> Dict[str, Any]:
941
947
  return {
942
948
  "status": pt.get("status", "not_run"),
943
949
  "reason": pt.get("reason"),
950
+ # DR attaches derived-surface provenance to gate-skipped
951
+ # sections too (a successful-but-empty derivation) — the skip
952
+ # path must not drop it (None on raw routes, same as below).
953
+ "pre_period_source": pt.get("pre_period_source"),
944
954
  }
945
955
  return {
946
956
  "status": "computed",
@@ -956,6 +966,14 @@ def _lift_pre_trends(dr: Optional[Dict[str, Any]]) -> Dict[str, Any]:
956
966
  # ``verdict == "inconclusive"`` per ``_pt_event_study``'s
957
967
  # inconclusive branch (``diagnostic_report.py:999``).
958
968
  "n_dropped_undefined": pt.get("n_dropped_undefined"),
969
+ # Provenance of the pre-period surface: "aggregate_event_study"
970
+ # when DR derived it from the post-fit
971
+ # ``results.aggregate('event_study')`` container. BR always emits
972
+ # the key — ``None`` on raw-field routes (DR itself omits the key
973
+ # there; ``dict.get`` maps that to None). Lifted explicitly for
974
+ # the same reason as ``n_dropped_undefined`` — this function is a
975
+ # field whitelist.
976
+ "pre_period_source": pt.get("pre_period_source"),
959
977
  "reason": pt.get("reason"),
960
978
  # Carry the denominator df through when the survey F-reference
961
979
  # branch was used so BR consumers can flag the finite-sample
@@ -45,7 +45,7 @@ from diff_diff.survey import (
45
45
  build_unit_first_row_index,
46
46
  compute_survey_vcov,
47
47
  )
48
- from diff_diff.utils import safe_inference, validate_n_bootstrap
48
+ from diff_diff.utils import safe_inference, validate_anticipation, validate_n_bootstrap
49
49
 
50
50
  if TYPE_CHECKING:
51
51
  from diff_diff.survey import ResolvedSurveyDesign, SurveyDesign
@@ -182,7 +182,8 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
182
182
  ``P(D=d_L) > 0``) and no never-treated units present. Single-cohort only
183
183
  (multi-cohort and ``covariates=`` raise ``NotImplementedError``).
184
184
  anticipation : int, default=0
185
- Number of periods of treatment anticipation.
185
+ Number of periods of treatment anticipation. Must be a
186
+ non-negative integer; ``bool`` is rejected.
186
187
  base_period : str, default="varying"
187
188
  ``"varying"`` or ``"universal"``.
188
189
  alpha : float, default=0.05
@@ -310,12 +311,18 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
310
311
  self._validate_constrained_params()
311
312
 
312
313
  def _validate_constrained_params(self) -> None:
313
- """Validate control_group, base_period, and estimation_method values."""
314
+ """Validate control_group, base_period, and estimation_method values.
315
+
316
+ Also validates ``anticipation`` and re-assigns it as a normalized
317
+ Python ``int`` — idempotent on an already-normalized value, so a
318
+ re-run never changes fitted config.
319
+ """
314
320
  if self.control_group not in self._VALID_CONTROL_GROUPS:
315
321
  raise ValueError(
316
322
  f"Invalid control_group: '{self.control_group}'. "
317
323
  f"Must be one of {self._VALID_CONTROL_GROUPS}."
318
324
  )
325
+ self.anticipation = validate_anticipation(self.anticipation)
319
326
  if self.base_period not in self._VALID_BASE_PERIODS:
320
327
  raise ValueError(
321
328
  f"Invalid base_period: '{self.base_period}'. "
@@ -442,6 +449,14 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
442
449
  else:
443
450
  aggregate = None
444
451
 
452
+ # Fit-time re-check: __init__ and set_params validate eagerly, so
453
+ # this only catches DIRECT attribute mutation (est.anticipation = ...)
454
+ # — an out-of-domain value silently changes the ESTIMAND. The
455
+ # assignment also re-normalizes a mutated numpy scalar to int. Placed
456
+ # AFTER the deprecation shim so a caller who both mutated and passed
457
+ # a deprecated argument still sees the FutureWarning before the raise.
458
+ self.anticipation = validate_anticipation(self.anticipation)
459
+
445
460
  # 1. Validate & prepare
446
461
  _VALID_AGGREGATES = (None, "dose", "eventstudy")
447
462
  if aggregate not in _VALID_AGGREGATES: