diff-diff 3.9.1__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 (122) hide show
  1. {diff_diff-3.9.1 → diff_diff-3.10.0}/PKG-INFO +3 -2
  2. {diff_diff-3.9.1 → diff_diff-3.10.0}/README.md +1 -0
  3. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/__init__.py +6 -1
  4. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/_staggered_triple_diff_engine.py +2 -0
  5. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/aggregation.py +29 -4
  6. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/bootstrap_chunking.py +17 -0
  7. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/bootstrap_utils.py +113 -6
  8. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/datasets.py +5 -1
  9. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/diagnostic_report.py +9 -3
  10. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/efficient_did.py +57 -52
  11. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/efficient_did_aggregation.py +36 -7
  12. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/efficient_did_bootstrap.py +99 -15
  13. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/efficient_did_results.py +114 -23
  14. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/estimators.py +16 -4
  15. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/guides/llms-autonomous.txt +41 -1
  16. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/guides/llms-full.txt +110 -18
  17. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/guides/llms-practitioner.txt +38 -14
  18. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/guides/llms.txt +2 -1
  19. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/imputation.py +9 -3
  20. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/linalg.py +84 -25
  21. diff_diff-3.10.0/diff_diff/lwdid.py +4503 -0
  22. diff_diff-3.10.0/diff_diff/lwdid_randomization.py +508 -0
  23. diff_diff-3.10.0/diff_diff/lwdid_results.py +826 -0
  24. diff_diff-3.10.0/diff_diff/lwdid_sensitivity.py +907 -0
  25. diff_diff-3.10.0/diff_diff/lwdid_staggered.py +817 -0
  26. diff_diff-3.10.0/diff_diff/lwdid_visualization.py +303 -0
  27. diff_diff-3.10.0/diff_diff/lwdid_wild_bootstrap.py +360 -0
  28. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/practitioner.py +28 -28
  29. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/results.py +2 -0
  30. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/results_base.py +3 -2
  31. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/spillover.py +32 -0
  32. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/staggered.py +54 -80
  33. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/staggered_bootstrap.py +126 -9
  34. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/staggered_results.py +126 -25
  35. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/twfe.py +14 -14
  36. {diff_diff-3.9.1 → diff_diff-3.10.0}/pyproject.toml +2 -2
  37. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/Cargo.lock +7 -7
  38. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/Cargo.toml +1 -1
  39. {diff_diff-3.9.1 → diff_diff-3.10.0}/LICENSE +0 -0
  40. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/_backend.py +0 -0
  41. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/_base.py +0 -0
  42. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/_deprecation.py +0 -0
  43. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/_guides_api.py +0 -0
  44. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/_nprobust_port.py +0 -0
  45. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/_rdrobust_port.py +0 -0
  46. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/_reporting_helpers.py +0 -0
  47. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/agent_workflow.py +0 -0
  48. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/bacon.py +0 -0
  49. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/balancing.py +0 -0
  50. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/business_report.py +0 -0
  51. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/chaisemartin_dhaultfoeuille.py +0 -0
  52. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
  53. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
  54. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/changes_in_changes.py +0 -0
  55. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/changes_in_changes_results.py +0 -0
  56. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/conformal.py +0 -0
  57. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/conley.py +0 -0
  58. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/continuous_did.py +0 -0
  59. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/continuous_did_aggregation.py +0 -0
  60. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/continuous_did_bspline.py +0 -0
  61. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/continuous_did_results.py +0 -0
  62. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/diagnostics.py +0 -0
  63. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/efficient_did_covariates.py +0 -0
  64. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/efficient_did_weights.py +0 -0
  65. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/guides/__init__.py +0 -0
  66. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/had.py +0 -0
  67. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/had_pretests.py +0 -0
  68. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/honest_did.py +0 -0
  69. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/imputation_aggregation.py +0 -0
  70. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/imputation_bootstrap.py +0 -0
  71. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/imputation_results.py +0 -0
  72. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/local_linear.py +0 -0
  73. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/lpdid.py +0 -0
  74. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/lpdid_results.py +0 -0
  75. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/mmm.py +0 -0
  76. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/power.py +0 -0
  77. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/prep.py +0 -0
  78. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/prep_dgp.py +0 -0
  79. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/pretrends.py +0 -0
  80. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/profile.py +0 -0
  81. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/rdd.py +0 -0
  82. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/rddensity.py +0 -0
  83. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/rdplot.py +0 -0
  84. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/stacked_did.py +0 -0
  85. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/stacked_did_results.py +0 -0
  86. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/staggered_aggregation.py +0 -0
  87. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/staggered_triple_diff.py +0 -0
  88. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/staggered_triple_diff_results.py +0 -0
  89. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/sun_abraham.py +0 -0
  90. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/survey.py +0 -0
  91. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/synthetic_control.py +0 -0
  92. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/synthetic_control_results.py +0 -0
  93. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/synthetic_did.py +0 -0
  94. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/triple_diff.py +0 -0
  95. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/trop.py +0 -0
  96. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/trop_global.py +0 -0
  97. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/trop_local.py +0 -0
  98. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/trop_results.py +0 -0
  99. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/two_stage.py +0 -0
  100. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/two_stage_aggregation.py +0 -0
  101. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/two_stage_bootstrap.py +0 -0
  102. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/two_stage_results.py +0 -0
  103. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/utils.py +0 -0
  104. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/visualization/__init__.py +0 -0
  105. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/visualization/_common.py +0 -0
  106. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/visualization/_continuous.py +0 -0
  107. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/visualization/_diagnostic.py +0 -0
  108. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/visualization/_event_study.py +0 -0
  109. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/visualization/_power.py +0 -0
  110. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/visualization/_staggered.py +0 -0
  111. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/visualization/_synthetic.py +0 -0
  112. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/wooldridge.py +0 -0
  113. {diff_diff-3.9.1 → diff_diff-3.10.0}/diff_diff/wooldridge_results.py +0 -0
  114. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/build.rs +0 -0
  115. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/src/alloc_profile.rs +0 -0
  116. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/src/batched_solve.rs +0 -0
  117. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/src/bootstrap.rs +0 -0
  118. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/src/demean.rs +0 -0
  119. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/src/lib.rs +0 -0
  120. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/src/linalg.rs +0 -0
  121. {diff_diff-3.9.1 → diff_diff-3.10.0}/rust/src/trop.rs +0 -0
  122. {diff_diff-3.9.1 → 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.1
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.2 ; 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,6 +175,7 @@ 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
@@ -120,6 +120,7 @@ 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
@@ -160,6 +160,8 @@ 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,
165
167
  meridian_calibration_mask,
@@ -366,7 +368,7 @@ def __getattr__(name: str) -> _Any:
366
368
  raise AttributeError(f"module 'diff_diff' has no attribute {name!r}")
367
369
 
368
370
 
369
- __version__ = "3.9.1"
371
+ __version__ = "3.10.0"
370
372
  __all__ = [
371
373
  # Estimators
372
374
  "DifferenceInDifferences",
@@ -459,6 +461,9 @@ __all__ = [
459
461
  # LPDiD (Local Projections DiD)
460
462
  "LPDiD",
461
463
  "LPDiDResults",
464
+ # LWDiD (Lee & Wooldridge rolling transformation DiD)
465
+ "LWDiD",
466
+ "LWDiDResults",
462
467
  # Visualization
463
468
  "plot_bacon",
464
469
  "plot_event_study",
@@ -145,6 +145,8 @@ class _StaggeredTripleDiffEngineMixin:
145
145
  unit: Optional[str] = None,
146
146
  precomputed: Any = None,
147
147
  cband: bool = True,
148
+ *,
149
+ _replay_bitgen_state: Optional[Dict[str, Any]] = None,
148
150
  ) -> Any: ...
149
151
 
150
152
  def _fit_staggered_core(
@@ -553,7 +553,11 @@ class AggregationKit:
553
553
  ``cband_crit_value`` is ``None`` both when bands were disabled and
554
554
  when no aggregation ran, so it cannot distinguish the two.
555
555
  bootstrap : AggregationKit.BootstrapReplaySpec or None
556
- 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).
557
561
  """
558
562
 
559
563
  bookkeeping: Dict[str, Any]
@@ -574,9 +578,29 @@ class BootstrapReplaySpec:
574
578
  ``set_params(n_bootstrap=...)`` silently changes - and can truncate - the
575
579
  replayed stream.
576
580
 
577
- This records the generator state plus the parameters BY VALUE and rebuilds
578
- the stream through a module-level factory, which replays bit-identically,
579
- 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.
580
604
  """
581
605
 
582
606
  bitgen_state: Dict[str, Any]
@@ -585,6 +609,7 @@ class BootstrapReplaySpec:
585
609
  weight_type: str
586
610
  block_size: Optional[int] = None
587
611
  expand_index: Optional[np.ndarray] = None
612
+ backend: Optional[str] = None
588
613
 
589
614
  def rebuild(self) -> Any:
590
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
@@ -1681,7 +1681,11 @@ def load_walmart(force_download: bool = False) -> pd.DataFrame:
1681
1681
  ... )
1682
1682
  """
1683
1683
  url = "http://fmwww.bc.edu/repec/bocode/l/lw_walmart.dta"
1684
- sha256 = "410885572143dceb9daa643a8097768f1bc3493f9437451a9e4d1d5dc1e18d14"
1684
+ # Re-pinned 2026-08-20: upstream re-saved the .dta (container bytes
1685
+ # changed); every shared column verified numerically identical to the
1686
+ # previously pinned copy (29,371 rows, 1,277 counties) before adopting.
1687
+ # The Stata golden's provenance block keeps the capture-time hash.
1688
+ sha256 = "cf1b2d4aee396f0858fb315d0413ff82359e16e84dad68ce1d9be51e8c3e35ac"
1685
1689
 
1686
1690
  source = "lwdid_ssc_ancillary"
1687
1691
  try:
@@ -16,7 +16,10 @@ Design principles:
16
16
  recompute — for ImputationDiD a panel-backed recompute, for TwoStageDiD
17
17
  a fresh Stage-2 OLS + GMM sandwich over the retained frame; used only
18
18
  when the raw ``event_study_effects`` field is absent, and failing
19
- closed to an explicit skip on bootstrapped / kit-less fits), or
19
+ closed to an explicit skip on kit-less/legacy-pickle fits, the sibling
20
+ estimators' bootstrap gates, and backend-mismatched CS bootstrap
21
+ replays — bootstrapped CS fits themselves derive successfully via the
22
+ percentile-bootstrap replay), or
20
23
  produced by an existing diff-diff utility. May call
21
24
  ``check_parallel_trends`` / ``BaconDecomposition`` /
22
25
  ``EfficientDiD.hausman_pretest`` when the caller supplies the panel +
@@ -840,8 +843,11 @@ class DiagnosticReport:
840
843
  surface = candidate
841
844
  surface_dict = _surface_to_event_study_dict(candidate)
842
845
  except Exception as exc: # noqa: BLE001 — fail-soft by design:
843
- # expected failures are NotImplementedError (bootstrap
844
- # gates, pretrends+replicate) and ValueError (missing kit),
846
+ # expected failures are NotImplementedError (the sibling
847
+ # estimators' bootstrap gates, pretrends+replicate, CS
848
+ # legacy-pickle/backend-mismatch replay refusals — a
849
+ # bootstrapped CS fit itself now derives via the replay)
850
+ # and ValueError (missing kit),
845
851
  # but the surface builder can raise bare TypeError and this
846
852
  # resolver runs on the applicable_checks path with no outer
847
853
  # guard; an escaped exception would hard-fail the report.
@@ -30,7 +30,11 @@ import pandas as pd
30
30
 
31
31
  from diff_diff._base import BaseEstimator
32
32
  from diff_diff._deprecation import NOT_SUPPLIED
33
- from diff_diff.aggregation import AggregationKit
33
+ from diff_diff.aggregation import AggregationKit, BootstrapReplaySpec
34
+ from diff_diff.bootstrap_utils import (
35
+ apply_bootstrap_event_study_overrides,
36
+ apply_bootstrap_group_overrides,
37
+ )
34
38
  from diff_diff.efficient_did_aggregation import (
35
39
  _cluster_aggregate,
36
40
  _compute_se_from_eif,
@@ -118,6 +122,7 @@ def _build_edid_aggregation_kit(
118
122
  df_survey: Optional[float],
119
123
  alpha: float,
120
124
  anticipation: int,
125
+ bootstrap_results: Optional[EDiDBootstrapResults] = None,
121
126
  ) -> Optional[AggregationKit]:
122
127
  """Bundle the retained EIF payload + bookkeeping for post-fit aggregate().
123
128
 
@@ -138,6 +143,21 @@ def _build_edid_aggregation_kit(
138
143
  # Unreachable after fit()'s empty-effects raise; kept for the CS
139
144
  # guard shape (a kit with nothing to re-aggregate is not attached).
140
145
  return None
146
+ # STATE-ONLY replay carrier (the CS contract): the spec retains the RNG
147
+ # snapshot + generation-branch identity the run recorded, BY VALUE, so
148
+ # post-fit aggregate() can replay the fit-time multiplier bootstrap
149
+ # through the same engine. Its rebuild() factory is unused here — the
150
+ # engine re-derives the generation branch from the kit bookkeeping.
151
+ # None on analytical fits, where the recompute levels stay analytical.
152
+ replay_spec = None
153
+ if bootstrap_results is not None and bootstrap_results._replay_bitgen_state is not None:
154
+ replay_spec = BootstrapReplaySpec(
155
+ bitgen_state=bootstrap_results._replay_bitgen_state,
156
+ n_bootstrap=bootstrap_results.n_bootstrap,
157
+ n_units=int(n_units),
158
+ weight_type=bootstrap_results.weight_type,
159
+ backend=bootstrap_results._replay_backend,
160
+ )
141
161
  return AggregationKit(
142
162
  bookkeeping={
143
163
  # PRIVATE SNAPSHOTS of the aggregation inputs (CI review P0):
@@ -165,7 +185,7 @@ def _build_edid_aggregation_kit(
165
185
  alpha=alpha,
166
186
  anticipation=anticipation,
167
187
  cband=False,
168
- bootstrap=None,
188
+ bootstrap=replay_spec,
169
189
  )
170
190
 
171
191
 
@@ -513,20 +533,23 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
513
533
  ``.aggregate('group')`` / ``.aggregate('simple')`` /
514
534
  ``.aggregate('total')``. On
515
535
  bootstrapped fits (``n_bootstrap > 0``) the post-fit
516
- RECOMPUTE levels (``'event_study'``/``'group'``) fail closed
517
- — the deprecated fit-time path remains the supported route
518
- for those bootstrapped aggregated surfaces — while
519
- ``aggregate('simple')`` and, where supported,
520
- ``aggregate('total')`` relay the stored bootstrap
521
- inference and stay available (the per-level policy
522
- converged with row M-027).
536
+ RECOMPUTE levels (``'event_study'``/``'group'``) REPLAY the
537
+ fit-time multiplier bootstrap from the kit-retained RNG
538
+ state (percentile inference, allclose to a fit-time
539
+ aggregation; no refit needed), while ``aggregate('simple')``
540
+ and, where supported, ``aggregate('total')`` relay the
541
+ stored bootstrap inference (the per-level policy converged
542
+ with row M-027).
523
543
  balance_e : int, optional
524
544
  DEPRECATED (3.9, removed in 4.0, row M-120): moves onto
525
545
  post-fit ``aggregate()`` —
526
546
  ``results.aggregate('event_study', balance_e=2)``. EDiD's
527
547
  balance rule is the ANCHOR-HORIZON rule (keep cohorts with a
528
- finite effect at ``e == balance_e``), the same rule
529
- CallawaySantAnna uses.
548
+ finite effect at the anchor horizon), the same rule shape
549
+ CallawaySantAnna uses — with one keying-granularity
550
+ difference: EDiD anchors on the ``int(t - g)`` bucket while
551
+ CS keys raw ``t - g`` (identical on integer-period panels;
552
+ see the REGISTRY truncation Note).
530
553
  survey_design : SurveyDesign, optional
531
554
  Survey design specification for design-based inference.
532
555
  Applies survey weights to all means, covariances, and cohort
@@ -1364,46 +1387,14 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
1364
1387
  se = float(group_time_effects[gt]["se"])
1365
1388
  group_time_effects[gt]["t_stat"] = safe_inference(eff, se, alpha=self.alpha)[0]
1366
1389
 
1367
- es_cis = bootstrap_results.event_study_cis
1368
- es_pvs = bootstrap_results.event_study_p_values
1369
- if (
1370
- event_study_effects is not None
1371
- and bootstrap_results.event_study_ses is not None
1372
- and es_cis is not None
1373
- and es_pvs is not None
1374
- ):
1375
- for e in event_study_effects:
1376
- if e in bootstrap_results.event_study_ses:
1377
- event_study_effects[e]["se"] = bootstrap_results.event_study_ses[e]
1378
- event_study_effects[e]["conf_int"] = es_cis[e]
1379
- event_study_effects[e]["p_value"] = es_pvs[e]
1380
- eff = float(event_study_effects[e]["effect"])
1381
- se = float(event_study_effects[e]["se"])
1382
- event_study_effects[e]["t_stat"] = safe_inference(
1383
- eff, se, alpha=self.alpha
1384
- )[0]
1385
-
1386
- g_cis = bootstrap_results.group_effect_cis
1387
- g_pvs = bootstrap_results.group_effect_p_values
1388
- if (
1389
- group_effects is not None
1390
- and bootstrap_results.group_effect_ses is not None
1391
- and g_cis is not None
1392
- and g_pvs is not None
1393
- ):
1394
- for g in group_effects:
1395
- if g in bootstrap_results.group_effect_ses:
1396
- group_effects[g]["se"] = bootstrap_results.group_effect_ses[g]
1397
- group_effects[g]["conf_int"] = g_cis[g]
1398
- group_effects[g]["p_value"] = g_pvs[g]
1399
- eff = float(group_effects[g]["effect"])
1400
- se = float(group_effects[g]["se"])
1401
- group_effects[g]["t_stat"] = safe_inference(eff, se, alpha=self.alpha)[0]
1402
- # Percentile-bootstrap inference has no analytical df;
1403
- # clear the provenance key the analytical pass recorded
1404
- # (the CS precedent) so bootstrap rows never publish an
1405
- # analytical survey df beside percentile p/CI.
1406
- group_effects[g]["df_used"] = None
1390
+ # ES/group percentile overrides via the shared appliers (the same
1391
+ # implementations the post-fit aggregate() replay runs — one
1392
+ # code path, no fit-vs-replay drift). The appliers carry the
1393
+ # availability guards and the group df_used clearing internally.
1394
+ apply_bootstrap_event_study_overrides(
1395
+ event_study_effects, bootstrap_results, self.alpha
1396
+ )
1397
+ apply_bootstrap_group_overrides(group_effects, bootstrap_results, self.alpha)
1407
1398
 
1408
1399
  # ----- Build results -----
1409
1400
  self.results_ = EfficientDiDResults(
@@ -1487,6 +1478,7 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
1487
1478
  df_survey=_survey_df_post_overall,
1488
1479
  alpha=self.alpha,
1489
1480
  anticipation=self.anticipation,
1481
+ bootstrap_results=bootstrap_results,
1490
1482
  )
1491
1483
  self.is_fitted_ = True
1492
1484
  return self.results_
@@ -1637,8 +1629,12 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
1637
1629
  ) -> Dict[int, Tuple[float, np.ndarray]]:
1638
1630
  """Aggregate (g,t) effects to post-treatment ES(e) with WIF-corrected EIF."""
1639
1631
  by_e: Dict[int, List[Tuple[Tuple, float, float, np.ndarray]]] = {}
1632
+ _has_fractional = False
1640
1633
  for (g, t), d in gt_effects.items():
1641
- e = int(t - g)
1634
+ raw_e = t - g
1635
+ e = int(raw_e)
1636
+ if raw_e != e:
1637
+ _has_fractional = True
1642
1638
  if e < -ant:
1643
1639
  continue
1644
1640
  if not np.isfinite(d["effect"]):
@@ -1653,6 +1649,15 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
1653
1649
  by_e[e] = []
1654
1650
  by_e[e].append(((g, t), d["effect"], pg, eif_vec))
1655
1651
 
1652
+ if _has_fractional:
1653
+ warnings.warn(
1654
+ "Fractional relative times detected: Hausman pre-test "
1655
+ "horizons are bucketed by int(t - g) (truncation toward "
1656
+ "zero). See the EfficientDiD REGISTRY truncation Note.",
1657
+ UserWarning,
1658
+ stacklevel=3,
1659
+ )
1660
+
1656
1661
  result: Dict[int, Tuple[float, np.ndarray]] = {}
1657
1662
  for e, items in by_e.items():
1658
1663
  if e < 0:
@@ -22,10 +22,13 @@ Contents:
22
22
  is what keeps ``aggregate()`` off an ``_estimator_ref``.
23
23
 
24
24
  The numerical content of every function in this module is byte-identical to
25
- its pre-extraction form, with ONE additive exception recorded in the M-023
26
- ledger notes: ``_aggregate_by_group`` records a per-row ``df_used`` key (the
25
+ its pre-extraction form, with the exceptions recorded in the M-023 ledger
26
+ notes: ``_aggregate_by_group`` records a per-row ``df_used`` key (the
27
27
  ``self._survey_df`` value at that row's ``safe_inference`` call) so the
28
- post-fit group relay can publish exact per-row df provenance.
28
+ post-fit group relay can publish exact per-row df provenance;
29
+ ``_aggregate_event_study`` counts DISTINCT cohorts in ``n_groups`` (identity
30
+ on integer panels) and warns once when fractional horizons are truncation-
31
+ bucketed (see the EfficientDiD REGISTRY truncation Note).
29
32
  """
30
33
 
31
34
  import warnings
@@ -325,7 +328,12 @@ class _EfficientAggregationMixin:
325
328
  cluster_indices: Optional[np.ndarray] = None,
326
329
  n_clusters: Optional[int] = None,
327
330
  ) -> Dict[int, Dict[str, Any]]:
328
- """Aggregate ATT(g,t) by relative time e = t - g.
331
+ """Aggregate ATT(g,t) by relative time ``e = int(t - g)``.
332
+
333
+ On integer-period panels the ``int()`` is the identity. Fractional-
334
+ period panels are truncation-bucketed toward zero (a documented
335
+ deviation from the exact-relative-time equation — see the
336
+ EfficientDiD REGISTRY truncation Note) and emit a ``UserWarning``.
329
337
 
330
338
  Parameters
331
339
  ----------
@@ -346,15 +354,30 @@ class _EfficientAggregationMixin:
346
354
  unit_cohorts : ndarray, optional
347
355
  Cohort assignment for each unit (for WIF correction).
348
356
  """
349
- # Organize by relative time
357
+ # Organize by relative time. Fractional horizons truncation-bucket
358
+ # (int() toward zero) — a lossy, documented convention that must not
359
+ # stay invisible to the user (no-silent-failures).
360
+ _has_fractional = False
350
361
  effects_by_e: Dict[int, List[Tuple[Tuple[Any, Any], float, float]]] = {}
351
362
  for (g, t), data in group_time_effects.items():
352
363
  if not np.isfinite(data["effect"]):
353
364
  continue
354
- e = int(t - g)
365
+ raw_e = t - g
366
+ e = int(raw_e)
367
+ if raw_e != e:
368
+ _has_fractional = True
355
369
  if e not in effects_by_e:
356
370
  effects_by_e[e] = []
357
371
  effects_by_e[e].append(((g, t), data["effect"], cohort_fractions.get(g, 0.0)))
372
+ if _has_fractional:
373
+ warnings.warn(
374
+ "Fractional relative times detected: event-study horizons are "
375
+ "bucketed by int(t - g) (truncation toward zero), pooling "
376
+ "fractional horizons into integer buckets. See the "
377
+ "EfficientDiD REGISTRY truncation Note.",
378
+ UserWarning,
379
+ stacklevel=2,
380
+ )
358
381
 
359
382
  # Balance if requested
360
383
  if balance_e is not None:
@@ -441,7 +464,13 @@ class _EfficientAggregationMixin:
441
464
  "t_stat": t_stat,
442
465
  "p_value": p_val,
443
466
  "conf_int": ci,
444
- "n_groups": len(elist),
467
+ # DISTINCT cohorts in the bucket (the cohort-count n column): identity
468
+ # with len(elist) on integer panels (one cell per cohort per
469
+ # bucket); on fractional panels truncation-bucketing pools
470
+ # multiple cells per cohort and a raw cell count would
471
+ # over-count. Weights above remain per-cell (cell-mass within
472
+ # the bucket — see the REGISTRY truncation Note).
473
+ "n_groups": len({gt[0] for gt in gt_pairs}),
445
474
  }
446
475
 
447
476
  return result