diff-diff 3.11.0__tar.gz → 3.11.1__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 (127) hide show
  1. {diff_diff-3.11.0 → diff_diff-3.11.1}/PKG-INFO +1 -1
  2. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/__init__.py +1 -1
  3. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_staggered_triple_diff_engine.py +14 -2
  4. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille.py +6 -5
  5. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/continuous_did.py +92 -17
  6. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/continuous_did_results.py +18 -2
  7. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/dml_did.py +127 -37
  8. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/dml_did_results.py +6 -3
  9. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/efficient_did.py +39 -4
  10. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/efficient_did_results.py +25 -7
  11. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/estimators.py +2 -2
  12. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/guides/llms-autonomous.txt +6 -5
  13. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/guides/llms-full.txt +6 -3
  14. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/guides/llms-practitioner.txt +2 -2
  15. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/guides/llms.txt +1 -1
  16. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/had.py +19 -4
  17. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/had_pretests.py +6 -1
  18. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/honest_did.py +21 -60
  19. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/imputation.py +13 -1
  20. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/imputation_results.py +24 -7
  21. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lpdid.py +1 -1
  22. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/results_base.py +12 -9
  23. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/spillover.py +4 -1
  24. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/staggered.py +28 -4
  25. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/sun_abraham.py +5 -5
  26. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/survey.py +1 -1
  27. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/synthetic_did.py +5 -0
  28. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/triple_diff.py +1 -1
  29. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/trop.py +4 -1
  30. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/twfe.py +1 -1
  31. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/two_stage.py +1 -1
  32. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/wooldridge.py +4 -2
  33. {diff_diff-3.11.0 → diff_diff-3.11.1}/pyproject.toml +1 -1
  34. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/Cargo.lock +1 -1
  35. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/Cargo.toml +1 -1
  36. {diff_diff-3.11.0 → diff_diff-3.11.1}/LICENSE +0 -0
  37. {diff_diff-3.11.0 → diff_diff-3.11.1}/README.md +0 -0
  38. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_backend.py +0 -0
  39. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_base.py +0 -0
  40. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_crossfit.py +0 -0
  41. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_deprecation.py +0 -0
  42. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_dr_scores.py +0 -0
  43. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_guides_api.py +0 -0
  44. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_learners.py +0 -0
  45. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_nprobust_port.py +0 -0
  46. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_rdrobust_port.py +0 -0
  47. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/_reporting_helpers.py +0 -0
  48. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/agent_workflow.py +0 -0
  49. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/aggregation.py +0 -0
  50. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/bacon.py +0 -0
  51. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/balancing.py +0 -0
  52. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/bootstrap_chunking.py +0 -0
  53. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/bootstrap_utils.py +0 -0
  54. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/business_report.py +0 -0
  55. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
  56. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
  57. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/changes_in_changes.py +0 -0
  58. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/changes_in_changes_results.py +0 -0
  59. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/conformal.py +0 -0
  60. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/conley.py +0 -0
  61. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/continuous_did_aggregation.py +0 -0
  62. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/continuous_did_bspline.py +0 -0
  63. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/datasets.py +0 -0
  64. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/diagnostic_report.py +0 -0
  65. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/diagnostics.py +0 -0
  66. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/efficient_did_aggregation.py +0 -0
  67. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/efficient_did_bootstrap.py +0 -0
  68. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/efficient_did_covariates.py +0 -0
  69. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/efficient_did_weights.py +0 -0
  70. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/guides/__init__.py +0 -0
  71. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/imputation_aggregation.py +0 -0
  72. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/imputation_bootstrap.py +0 -0
  73. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/linalg.py +0 -0
  74. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/local_linear.py +0 -0
  75. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lpdid_results.py +0 -0
  76. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lwdid.py +0 -0
  77. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lwdid_randomization.py +0 -0
  78. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lwdid_results.py +0 -0
  79. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lwdid_sensitivity.py +0 -0
  80. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lwdid_staggered.py +0 -0
  81. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lwdid_visualization.py +0 -0
  82. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/lwdid_wild_bootstrap.py +0 -0
  83. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/mmm.py +0 -0
  84. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/power.py +0 -0
  85. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/practitioner.py +0 -0
  86. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/prep.py +0 -0
  87. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/prep_dgp.py +0 -0
  88. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/pretrends.py +0 -0
  89. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/profile.py +0 -0
  90. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/rdd.py +0 -0
  91. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/rddensity.py +0 -0
  92. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/rdplot.py +0 -0
  93. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/results.py +0 -0
  94. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/stacked_did.py +0 -0
  95. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/stacked_did_results.py +0 -0
  96. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/staggered_aggregation.py +0 -0
  97. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/staggered_bootstrap.py +0 -0
  98. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/staggered_results.py +0 -0
  99. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/staggered_triple_diff.py +0 -0
  100. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/staggered_triple_diff_results.py +0 -0
  101. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/synthetic_control.py +0 -0
  102. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/synthetic_control_results.py +0 -0
  103. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/trop_global.py +0 -0
  104. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/trop_local.py +0 -0
  105. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/trop_results.py +0 -0
  106. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/two_stage_aggregation.py +0 -0
  107. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/two_stage_bootstrap.py +0 -0
  108. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/two_stage_results.py +0 -0
  109. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/utils.py +0 -0
  110. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/visualization/__init__.py +0 -0
  111. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/visualization/_common.py +0 -0
  112. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/visualization/_continuous.py +0 -0
  113. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/visualization/_diagnostic.py +0 -0
  114. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/visualization/_event_study.py +0 -0
  115. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/visualization/_power.py +0 -0
  116. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/visualization/_staggered.py +0 -0
  117. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/visualization/_synthetic.py +0 -0
  118. {diff_diff-3.11.0 → diff_diff-3.11.1}/diff_diff/wooldridge_results.py +0 -0
  119. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/build.rs +0 -0
  120. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/src/alloc_profile.rs +0 -0
  121. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/src/batched_solve.rs +0 -0
  122. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/src/bootstrap.rs +0 -0
  123. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/src/demean.rs +0 -0
  124. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/src/lib.rs +0 -0
  125. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/src/linalg.rs +0 -0
  126. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/src/trop.rs +0 -0
  127. {diff_diff-3.11.0 → diff_diff-3.11.1}/rust/src/weights.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 3.11.0
3
+ Version: 3.11.1
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: OS Independent
@@ -371,7 +371,7 @@ def __getattr__(name: str) -> _Any:
371
371
  raise AttributeError(f"module 'diff_diff' has no attribute {name!r}")
372
372
 
373
373
 
374
- __version__ = "3.11.0"
374
+ __version__ = "3.11.1"
375
375
  __all__ = [
376
376
  # Estimators
377
377
  "DifferenceInDifferences",
@@ -295,8 +295,20 @@ class _StaggeredTripleDiffEngineMixin:
295
295
  if resolved_survey is not None and survey_metadata is not None:
296
296
  resolved_survey_unit = precomputed.get("resolved_survey_unit")
297
297
  if resolved_survey_unit is not None:
298
- unit_w = resolved_survey_unit.weights
299
- survey_metadata = compute_survey_metadata(resolved_survey_unit, unit_w)
298
+ from diff_diff.survey import _extract_unit_survey_weights
299
+
300
+ # Raw (pre-normalization) unit weights for metadata
301
+ # provenance: compute_survey_metadata expects the ORIGINAL
302
+ # scale (resolve() rescales pweights to mean 1, so the
303
+ # resolved weights would misreport sum_weights/
304
+ # weight_range; scale-invariant fields are unaffected
305
+ # either way). Read from ``data``: ``df`` has first_treat
306
+ # overwritten by this point.
307
+ assert survey_design is not None
308
+ raw_unit_w = _extract_unit_survey_weights(
309
+ data, unit, survey_design, precomputed["all_units"]
310
+ )
311
+ survey_metadata = compute_survey_metadata(resolved_survey_unit, raw_unit_w)
300
312
 
301
313
  # Survey df for t-distribution critical values
302
314
  df_survey = precomputed.get("df_survey")
@@ -51,7 +51,7 @@ from diff_diff.chaisemartin_dhaultfoeuille_results import (
51
51
  DCDHBootstrapResults,
52
52
  )
53
53
  from diff_diff.linalg import solve_ols
54
- from diff_diff.utils import safe_inference
54
+ from diff_diff.utils import safe_inference, validate_n_bootstrap
55
55
 
56
56
  __all__ = [
57
57
  "ChaisemartinDHaultfoeuille",
@@ -762,8 +762,8 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin, BaseE
762
762
  )
763
763
  if not 0.0 < alpha < 1.0:
764
764
  raise ValueError(f"alpha must be in (0, 1), got {alpha}")
765
- if n_bootstrap < 0:
766
- raise ValueError(f"n_bootstrap must be non-negative, got {n_bootstrap}")
765
+ # Shared type guard (rejects bool/float, floor stays 0).
766
+ validate_n_bootstrap(n_bootstrap)
767
767
  if by_path is not None:
768
768
  if isinstance(by_path, bool) or not isinstance(by_path, int):
769
769
  raise ValueError(
@@ -836,8 +836,9 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin, BaseE
836
836
  )
837
837
  if not 0.0 < self.alpha < 1.0:
838
838
  raise ValueError(f"alpha must be in (0, 1), got {self.alpha}")
839
- if self.n_bootstrap < 0:
840
- raise ValueError(f"n_bootstrap must be non-negative, got {self.n_bootstrap}")
839
+ # Shared type guard (kept aligned with __init__ so the two sites
840
+ # cannot drift).
841
+ validate_n_bootstrap(self.n_bootstrap)
841
842
  if self.by_path is not None:
842
843
  if isinstance(self.by_path, bool) or not isinstance(self.by_path, int):
843
844
  raise ValueError(
@@ -506,6 +506,18 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
506
506
  if col not in df.columns:
507
507
  raise ValueError(f"Column '{col}' not found in data.")
508
508
 
509
+ # Snapshot the raw survey-weight column BEFORE any df mutation
510
+ # (never-treated dose zeroing, first_treat inf->0, to_numeric): a
511
+ # design whose weight column aliases a mutable role column (e.g.
512
+ # weights == dose) must still surface the user's ORIGINAL values in
513
+ # survey_metadata. Per-unit via groupby-first so the later
514
+ # dose-filter (which drops whole units) cannot desync alignment.
515
+ raw_unit_w_meta: Optional[pd.Series] = None
516
+ if survey_design is not None and survey_design.weights is not None:
517
+ # `is not None`, not truthiness: resolve() treats any non-None
518
+ # string — an empty-string column name included — as a column.
519
+ raw_unit_w_meta = data.groupby(unit)[survey_design.weights].first()
520
+
509
521
  # Covariate-path guards (conditional parallel trends).
510
522
  if cov_cols:
511
523
  if survey_design is not None:
@@ -770,11 +782,20 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
770
782
  )
771
783
  lowest_dose = d_L
772
784
 
773
- # Re-resolve survey design on filtered df if rows were dropped
774
- # (survey arrays must align with df, not the original data)
785
+ # Re-resolve survey design on the filtered rows if rows were dropped
786
+ # (survey arrays must align with df, not the original data). Resolve
787
+ # from PRISTINE ``data`` rows, not the mutated working frame: the
788
+ # unfiltered path resolves from ``data``, and df's role-column
789
+ # coercions (never-treated dose zeroing, first_treat inf->0,
790
+ # to_numeric) must not leak into a design whose column aliases a
791
+ # mutated role column — resolving on df previously zero-weighted
792
+ # every never-treated unit when ``weights == dose``. The dose filter
793
+ # drops whole units and preserves row order, so the pristine
794
+ # unit-mask selection is row-for-row identical to df.
775
795
  if resolved_survey is not None and len(df) < len(data):
796
+ _kept_row_mask = data[unit].isin(set(df[unit].unique())).to_numpy()
776
797
  resolved_survey, survey_weights, survey_weight_type, survey_metadata = (
777
- _resolve_survey_for_fit(survey_design, df, "analytical")
798
+ _resolve_survey_for_fit(survey_design, data[_kept_row_mask], "analytical")
778
799
  )
779
800
 
780
801
  # 2. Precompute structures
@@ -973,6 +994,39 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
973
994
 
974
995
  _survey_df = None # Set by analytical branch when survey is active
975
996
 
997
+ # Recompute survey_metadata from the UNIT-level design on EVERY arm
998
+ # (degenerate no-post-cells, bootstrap, analytic) so reported
999
+ # sum_weights/effective_n/n_psu/df_survey describe one granularity —
1000
+ # the CS/EfficientDiD convention. Construction is byte-identical to
1001
+ # the ones inside _run_bootstrap and _compute_analytical_se.
1002
+ _unit_resolved_shared = None
1003
+ if resolved_survey is not None:
1004
+ # Built ONCE and threaded into the analytical/bootstrap helpers
1005
+ # below (they previously rebuilt it — on replicate designs that
1006
+ # copied the unit-by-replicate matrix twice).
1007
+ _unit_resolved_shared = resolved_survey.subset_to_units_by_row_idx(
1008
+ precomp["unit_first_panel_row"],
1009
+ unit_weights=precomp.get("unit_survey_weights"),
1010
+ )
1011
+ if resolved_survey is not None and survey_metadata is not None:
1012
+ from diff_diff.survey import compute_survey_metadata
1013
+
1014
+ _unit_resolved_meta = _unit_resolved_shared
1015
+ # Raw (pre-normalization) unit weights for metadata provenance:
1016
+ # compute_survey_metadata expects the ORIGINAL scale (resolve()
1017
+ # rescales pweights to mean 1; scale-invariant fields are
1018
+ # unaffected either way). ``raw_unit_w_meta`` was snapshotted
1019
+ # from pristine ``data`` before the df mutations; reindexing to
1020
+ # ``all_units`` (the dose-filtered unit order) keeps alignment —
1021
+ # survey weights are unit-constant (validated at resolve time).
1022
+ assert survey_design is not None
1023
+ raw_w_unit = (
1024
+ raw_unit_w_meta.reindex(precomp["all_units"]).to_numpy(dtype=np.float64)
1025
+ if raw_unit_w_meta is not None
1026
+ else np.ones(precomp["n_units"], dtype=np.float64)
1027
+ )
1028
+ survey_metadata = compute_survey_metadata(_unit_resolved_meta, raw_w_unit)
1029
+
976
1030
  if len(post_gt) == 0:
977
1031
  warnings.warn(
978
1032
  "No post-treatment (g,t) cells available for aggregation. "
@@ -1040,6 +1094,7 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
1040
1094
  agg_acrt_d,
1041
1095
  event_study_effects,
1042
1096
  resolved_survey=resolved_survey,
1097
+ pre_unit_resolved=_unit_resolved_shared,
1043
1098
  )
1044
1099
  att_d_se = boot_result["att_d_se"]
1045
1100
  att_d_ci_lower = boot_result["att_d_ci_lower"]
@@ -1080,6 +1135,7 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
1080
1135
  agg_att_d,
1081
1136
  agg_acrt_d,
1082
1137
  resolved_survey=resolved_survey,
1138
+ pre_unit_resolved=_unit_resolved_shared,
1083
1139
  )
1084
1140
  att_d_se = analytic["att_d_se"]
1085
1141
  acrt_d_se = analytic["acrt_d_se"]
@@ -1097,14 +1153,9 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
1097
1153
  ):
1098
1154
  _survey_df = 0
1099
1155
 
1100
- # Recompute survey_metadata from unit-level design so reported
1101
- # effective_n/n_psu/df_survey match the inference actually run
1102
- _unit_resolved = analytic.get("unit_resolved")
1103
- if _unit_resolved is not None:
1104
- from diff_diff.survey import compute_survey_metadata
1105
-
1106
- raw_w_unit = _unit_resolved.weights
1107
- survey_metadata = compute_survey_metadata(_unit_resolved, raw_w_unit)
1156
+ # (Unit-level survey_metadata is recomputed once for ALL
1157
+ # arms before the post_gt split; only the replicate-df
1158
+ # propagation below is analytic-arm-specific.)
1108
1159
 
1109
1160
  # Propagate replicate df override to survey_metadata for display
1110
1161
  # (but not the df=0 sentinel — keep metadata as None for undefined df)
@@ -1234,6 +1285,15 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
1234
1285
  rank_deficient_action=self.rank_deficient_action,
1235
1286
  event_study_effects=event_study_effects,
1236
1287
  survey_metadata=survey_metadata,
1288
+ # Per-row ES df provenance (M-092 completion): the survey df the
1289
+ # ES rows' safe_inference used. None when no ES surface was
1290
+ # built, on bootstrap fits (_survey_df stays None there), on
1291
+ # non-survey fits, and for the replicate-undefined 0 sentinel.
1292
+ event_study_df=(
1293
+ float(_survey_df)
1294
+ if (event_study_effects is not None and _survey_df is not None and _survey_df > 0)
1295
+ else None
1296
+ ),
1237
1297
  )
1238
1298
  # Post-fit aggregation kit (row M-025): attached on EVERY fit;
1239
1299
  # scalars-only on bootstrap fits (the ES route fails closed there).
@@ -1942,6 +2002,7 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
1942
2002
  agg_att_d: np.ndarray,
1943
2003
  agg_acrt_d: np.ndarray,
1944
2004
  resolved_survey: Optional["ResolvedSurveyDesign"] = None,
2005
+ pre_unit_resolved: Optional["ResolvedSurveyDesign"] = None,
1945
2006
  ) -> Dict[str, Any]:
1946
2007
  """Compute analytical SEs using influence functions."""
1947
2008
  n_units = precomp["n_units"]
@@ -2042,9 +2103,16 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
2042
2103
  # The resolved_survey has panel-level arrays (n_obs = n_units * n_periods),
2043
2104
  # but influence functions are unit-level (n_units). Build a unit-level
2044
2105
  # ResolvedSurveyDesign by subsetting to one obs per unit.
2045
- row_idx = precomp["unit_first_panel_row"]
2046
- unit_resolved = resolved_survey.subset_to_units_by_row_idx(
2047
- row_idx, unit_weights=precomp.get("unit_survey_weights")
2106
+ # Reuse the fit-level collapse when supplied (avoids copying
2107
+ # the unit-by-replicate matrix a second time on replicate
2108
+ # designs); construction is byte-identical.
2109
+ unit_resolved = (
2110
+ pre_unit_resolved
2111
+ if pre_unit_resolved is not None
2112
+ else resolved_survey.subset_to_units_by_row_idx(
2113
+ precomp["unit_first_panel_row"],
2114
+ unit_weights=precomp.get("unit_survey_weights"),
2115
+ )
2048
2116
  )
2049
2117
 
2050
2118
  X_ones = np.ones((n_units, 1))
@@ -2142,6 +2210,7 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
2142
2210
  original_acrt_d: np.ndarray,
2143
2211
  event_study_effects: Optional[Dict[int, Dict]],
2144
2212
  resolved_survey: Optional["ResolvedSurveyDesign"] = None,
2213
+ pre_unit_resolved: Optional["ResolvedSurveyDesign"] = None,
2145
2214
  ) -> Dict[str, Any]:
2146
2215
  """Run multiplier bootstrap inference."""
2147
2216
  if self.n_bootstrap < 50:
@@ -2172,9 +2241,15 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator):
2172
2241
  # Build unit-level ResolvedSurveyDesign for survey-aware bootstrap
2173
2242
  unit_resolved = None
2174
2243
  if resolved_survey is not None:
2175
- row_idx = precomp["unit_first_panel_row"]
2176
- unit_resolved = resolved_survey.subset_to_units_by_row_idx(
2177
- row_idx, unit_weights=precomp.get("unit_survey_weights")
2244
+ # Reuse the fit-level collapse when supplied (byte-identical
2245
+ # construction; avoids a second unit-by-replicate copy).
2246
+ unit_resolved = (
2247
+ pre_unit_resolved
2248
+ if pre_unit_resolved is not None
2249
+ else resolved_survey.subset_to_units_by_row_idx(
2250
+ precomp["unit_first_panel_row"],
2251
+ unit_weights=precomp.get("unit_survey_weights"),
2252
+ )
2178
2253
  )
2179
2254
 
2180
2255
  # Generate bootstrap weights — PSU-level when survey design is present
@@ -125,6 +125,11 @@ class ContinuousDiDResults(BaseResults, AggregationMixin):
125
125
  Random seed used for bootstrap.
126
126
  rank_deficient_action : str
127
127
  How rank deficiency is handled (``"warn"``, ``"error"``, ``"silent"``).
128
+ event_study_df : float or None
129
+ Scalar survey df governing the event-study rows' t-inference.
130
+ ``None`` on non-survey fits, on bootstrapped fits, when no
131
+ fit-time event-study surface was built, and for the
132
+ replicate-undefined ``0`` sentinel.
128
133
  """
129
134
 
130
135
  dose_response_att: DoseResponseCurve
@@ -176,9 +181,16 @@ class ContinuousDiDResults(BaseResults, AggregationMixin):
176
181
  # Survey design metadata (SurveyMetadata instance from diff_diff.survey)
177
182
  survey_metadata: Optional[Any] = field(default=None)
178
183
  # Post-fit aggregation kit (row M-025), attached by ContinuousDiD.fit().
179
- # Declared LAST for positional-__init__ compatibility. Only the
180
- # 'event_study' recompute reads it; 'simple'/'dose' are views.
184
+ # New fields are appended AFTER this one (positional-__init__
185
+ # compatibility). Only the 'event_study' recompute reads it;
186
+ # 'simple'/'dose' are views.
181
187
  _aggregation_kit: Optional[Any] = field(default=None, repr=False, compare=False)
188
+ # Scalar survey df governing the event-study rows' t-inference. None on
189
+ # non-survey fits, on bootstrapped fits (percentile inference; the ES
190
+ # recompute also fails closed there), when no event-study surface was
191
+ # built, and for the replicate-undefined 0 sentinel. Appended last per
192
+ # the positional-__init__ convention above.
193
+ event_study_df: Optional[float] = None
182
194
 
183
195
  # Post-fit aggregation routing (M-122 contract). ContinuousDiD's extra
184
196
  # 'dose' level is documented in the ledger row and v4-design section 6;
@@ -683,12 +695,16 @@ class ContinuousDiDResults(BaseResults, AggregationMixin):
683
695
  # cells exist.
684
696
  meta = bk["survey_metadata"]
685
697
  meta = dataclasses.replace(meta) if meta is not None else None
698
+ # Per-row df provenance: the kit's survey df is the value this
699
+ # route's safe_inference calls received; 0-sentinel normalized.
700
+ _es_df = bk["survey_df"]
686
701
  carrier = dataclasses.replace(
687
702
  self,
688
703
  event_study_effects=es,
689
704
  survey_metadata=meta,
690
705
  alpha=kit.alpha,
691
706
  anticipation=kit.anticipation,
707
+ event_study_df=(float(_es_df) if _es_df is not None and _es_df > 0 else None),
692
708
  # _provenance_kwargs reads base_period off the carrier - it
693
709
  # rides the kit like its siblings alpha/anticipation so
694
710
  # post-fit mutation of the public field cannot reach
@@ -1364,15 +1364,43 @@ class DMLDiD(CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, B
1364
1364
  "control_inf": inf_full[control_idx],
1365
1365
  }
1366
1366
 
1367
- # Per-cell SE. PSU designs (declared OR bare cluster=) route through
1368
- # the CS per-cell CR1 helper (3-valued contract: float = use it,
1369
- # NaN = unidentified clustered variance and MUST propagate,
1370
- # None = malformed -> fall back). Non-PSU survey designs use the
1371
- # weighted sqrt-sum (CS mirror: the full design enters aggregate SEs
1372
- # only); the no-survey branch is verbatim.
1367
+ # Per-cell SE. Replicate designs use IF-reweighting on the Hajek
1368
+ # payload (compute_replicate_if_variance; the same psi the aggregate
1369
+ # _se_from_psi call consumes) a zero or non-finite replicate
1370
+ # variance is degenerate and fails closed to NaN (stricter than the
1371
+ # shared aggregate clamp; REGISTRY DMLDiD Note). PSU designs
1372
+ # (declared OR bare cluster=) route through the CS per-cell CR1
1373
+ # helper (3-valued contract: float = use it, NaN = unidentified
1374
+ # clustered variance and MUST propagate, None = malformed -> fall
1375
+ # back). Non-PSU survey designs use the weighted sqrt-sum (CS
1376
+ # mirror: the full design enters aggregate SEs only); the no-survey
1377
+ # branch is verbatim.
1373
1378
  se: float
1374
1379
  with np.errstate(over="ignore", invalid="ignore"):
1375
- if (
1380
+ if resolved_survey_unit is not None and resolved_survey_unit.uses_replicate_variance:
1381
+ from diff_diff.survey import compute_replicate_if_variance
1382
+
1383
+ variance, n_valid_rep = compute_replicate_if_variance(
1384
+ inf_full, resolved_survey_unit
1385
+ )
1386
+ if not np.isfinite(variance) or variance <= 0.0:
1387
+ se = float("nan")
1388
+ else:
1389
+ se = float(np.sqrt(variance))
1390
+ # Per-cell df: min(design df, n_valid - 1) — the dCDH
1391
+ # _effective_df_survey rule, inlined. n_valid is computed
1392
+ # over the WHOLE replicate columns, so it equals R for every
1393
+ # cell in practice (defensive; REGISTRY Note). df_survey is
1394
+ # a CELL-LOCAL binding — never mutate
1395
+ # precomputed["df_survey"], which feeds the post-fit
1396
+ # aggregation kit.
1397
+ if df_survey is not None:
1398
+ df_survey = min(int(df_survey), int(n_valid_rep) - 1)
1399
+ else:
1400
+ # Undefined replicate df (QR rank <= 1): df=0 sentinel
1401
+ # -> NaN inference (CS sentinel parity).
1402
+ df_survey = 0
1403
+ elif (
1376
1404
  resolved_survey_unit is not None
1377
1405
  and getattr(resolved_survey_unit, "psu", None) is not None
1378
1406
  ):
@@ -1386,9 +1414,10 @@ class DMLDiD(CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, B
1386
1414
  else:
1387
1415
  se = float(np.sqrt(np.mean(psi_bar**2) / n_cell))
1388
1416
  # NOTE: `se` is deliberately OUTSIDE the non_finite_score gate on the
1389
- # design-based branches — a NaN from the CR1 helper is the
1390
- # unidentified-variance signal and must flow to safe_inference as a
1391
- # NaN-consistent inference tuple on a RETAINED cell.
1417
+ # design-based branches — a NaN from the CR1 helper (or a degenerate
1418
+ # replicate variance) is the unidentified-variance signal and must
1419
+ # flow to safe_inference as a NaN-consistent inference tuple on a
1420
+ # RETAINED cell.
1392
1421
  if resolved_survey_unit is None and not np.isfinite(se):
1393
1422
  diagnostics["skip_reason"] = "non_finite_score"
1394
1423
  return (
@@ -1760,12 +1789,28 @@ class DMLDiD(CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, B
1760
1789
  }
1761
1790
 
1762
1791
  # Per-cell SE (same dispatch as the panel cell; see the comment
1763
- # there): PSU designs -> CS per-cell CR1 helper (NaN propagates as
1792
+ # there): replicate designs -> IF-reweighting with the degenerate
1793
+ # fail-closed guard and the cell-local min(df, n_valid - 1) rule;
1794
+ # PSU designs -> CS per-cell CR1 helper (NaN propagates as
1764
1795
  # the deliberate unidentified-variance signal on a RETAINED cell);
1765
1796
  # non-PSU survey -> weighted sqrt-sum; no-survey verbatim.
1766
1797
  se: float
1767
1798
  with np.errstate(over="ignore", invalid="ignore"):
1768
- if (
1799
+ if resolved_survey_unit is not None and resolved_survey_unit.uses_replicate_variance:
1800
+ from diff_diff.survey import compute_replicate_if_variance
1801
+
1802
+ variance, n_valid_rep = compute_replicate_if_variance(
1803
+ inf_full, resolved_survey_unit
1804
+ )
1805
+ if not np.isfinite(variance) or variance <= 0.0:
1806
+ se = float("nan")
1807
+ else:
1808
+ se = float(np.sqrt(variance))
1809
+ if df_survey is not None:
1810
+ df_survey = min(int(df_survey), int(n_valid_rep) - 1)
1811
+ else:
1812
+ df_survey = 0
1813
+ elif (
1769
1814
  resolved_survey_unit is not None
1770
1815
  and getattr(resolved_survey_unit, "psu", None) is not None
1771
1816
  ):
@@ -1831,19 +1876,24 @@ class DMLDiD(CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, B
1831
1876
  Parameters
1832
1877
  ----------
1833
1878
  survey_design : SurveyDesign, optional
1834
- Complex survey design (pweight-only; full-design TSL
1835
- weights/strata/PSU/FPC). Declared designs weight the moment
1836
- kernels (Hajek p-hat/lambda-hat/theta), pass ``sample_weight``
1837
- into the nuisance learners (user learner objects must accept
1838
- ``sample_weight`` by keyword a learner without it is rejected
1839
- up front), switch cross-fitting to PSU-cohesive folds when the
1840
- PSU is strictly coarser than the sampling unit, and route the
1841
- per-cell and aggregate variances through the design-based
1842
- kernels with ``df = n_PSU - n_strata`` t-inference. Survey
1843
- support is a documented library extension of Chang (2020),
1844
- which assumes i.i.d. sampling Theorem 2's coverage claim
1845
- does not carry over (REGISTRY DMLDiD Notes). Replicate-weight
1846
- designs are not supported yet (fail closed; TODO.md).
1879
+ Complex survey design (pweight-only). Declared designs weight
1880
+ the moment kernels (Hajek p-hat/lambda-hat/theta) and pass
1881
+ ``sample_weight`` into the nuisance learners (user learner
1882
+ objects must accept ``sample_weight`` by keyword a learner
1883
+ without it is rejected up front). Two variance lanes:
1884
+ full-design TSL (weights/strata/PSU/FPC) switches cross-fitting
1885
+ to PSU-cohesive folds when the PSU is strictly coarser than the
1886
+ sampling unit and routes the per-cell and aggregate variances
1887
+ through the design-based kernels with ``df = n_PSU - n_strata``
1888
+ t-inference; replicate-weight designs (BRR / Fay / JK1 / JKn /
1889
+ SDR) compute per-cell AND aggregate variances by IF-reweighting
1890
+ the cross-fitted scores with ``df = rank(replicate matrix) - 1``
1891
+ t-inference (nuisances are not re-estimated per replicate;
1892
+ REGISTRY DMLDiD Note). Replicate designs reject ``cluster=``
1893
+ and ``n_bootstrap > 0`` combinations. Survey support is a
1894
+ documented library extension of Chang (2020), which assumes
1895
+ i.i.d. sampling — Theorem 2's coverage claim does not carry
1896
+ over (REGISTRY DMLDiD Notes).
1847
1897
  """
1848
1898
  df, covariates = self._validate_and_prepare(
1849
1899
  data, outcome, unit, time, first_treat, covariates
@@ -1866,13 +1916,18 @@ class DMLDiD(CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, B
1866
1916
  survey_metadata,
1867
1917
  ) = _resolve_survey_for_fit(survey_design, data, "analytical")
1868
1918
 
1869
- # Replicate designs fail closed FIRST (None-guarded): no replicate
1870
- # variance path exists for the cross-fitted scores yet.
1871
- if resolved_survey is not None and resolved_survey.uses_replicate_variance:
1919
+ # Replicate + bootstrap rejected FIRST (before any fit work)
1920
+ # replicate variance is an analytical alternative, not compatible
1921
+ # with bootstrap (CS parity, staggered.py).
1922
+ if (
1923
+ self.n_bootstrap > 0
1924
+ and resolved_survey is not None
1925
+ and resolved_survey.uses_replicate_variance
1926
+ ):
1872
1927
  raise NotImplementedError(
1873
- "DMLDiD does not support replicate-weight survey designs yet "
1874
- "(tracked in TODO.md); use a full-design SurveyDesign "
1875
- "(weights/strata/psu/fpc) instead."
1928
+ "DMLDiD bootstrap (n_bootstrap > 0) is not supported "
1929
+ "with replicate-weight survey designs. Replicate weights provide "
1930
+ "analytical variance; use n_bootstrap=0 instead."
1876
1931
  )
1877
1932
 
1878
1933
  # Raw (pre-normalization) per-obs design weights, for metadata
@@ -1886,7 +1941,7 @@ class DMLDiD(CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, B
1886
1941
  assert survey_design is not None
1887
1942
  raw_obs_weights = (
1888
1943
  data[survey_design.weights].values.astype(np.float64)
1889
- if survey_design.weights
1944
+ if survey_design.weights is not None
1890
1945
  else np.ones(len(data), dtype=np.float64)
1891
1946
  )
1892
1947
 
@@ -1902,6 +1957,25 @@ class DMLDiD(CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, B
1902
1957
  "drop or impute them before fitting"
1903
1958
  )
1904
1959
  cluster_ids_for_check = _cluster_col.to_numpy()
1960
+ # Reject replicate-weight + cluster= AFTER the column checks (CS
1961
+ # ordering: a bogus cluster name raises ValueError first).
1962
+ # Replicate IF variance is computed by replicate reweighting and
1963
+ # ignores PSU/cluster entirely (replicate_weights are mutually
1964
+ # exclusive with strata/psu/fpc) — honoring cluster= would
1965
+ # silently have no effect on the variance, and the inject-as-PSU
1966
+ # paths below would violate that mutual exclusion.
1967
+ if resolved_survey is not None and resolved_survey.uses_replicate_variance:
1968
+ raise NotImplementedError(
1969
+ f"DMLDiD(cluster={self.cluster!r}) is not "
1970
+ "supported with replicate-weight survey designs. "
1971
+ "Replicate-weight variance is computed by replicate "
1972
+ "reweighting (BRR / Fay / JK1 / JKn / SDR) and ignores "
1973
+ "PSU/cluster entirely — setting cluster= would silently "
1974
+ "have no effect on the variance estimate. Either omit "
1975
+ "cluster= (the replicate weights encode the design "
1976
+ "structure implicitly) or use a non-replicate survey "
1977
+ "design (with explicit strata/psu/fpc)."
1978
+ )
1905
1979
  if resolved_survey is None:
1906
1980
  # Bare cluster=: synthesize a PSU-only design. survey_metadata
1907
1981
  # stays None DELIBERATELY (it is the declared-survey marker:
@@ -2188,15 +2262,31 @@ class DMLDiD(CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, B
2188
2262
 
2189
2263
  # Overall ATT (simple aggregation over post-treatment finite cells).
2190
2264
  # overall_effective_df is non-None only when replicate variance
2191
- # dropped replicates unreachable while replicate designs are
2192
- # rejected, but kept for structural parity with CS.
2265
+ # dropped replicates (n_valid < R). MIN-CAP, not CS's replace: the
2266
+ # QR-rank design df stays the ceiling (CS's replace convention can
2267
+ # RAISE df above the design df — anti-conservative; deliberate
2268
+ # documented divergence, REGISTRY DMLDiD Note + CS-parity TODO row).
2193
2269
  overall_att, overall_se, overall_effective_df = self._aggregate_simple(
2194
2270
  group_time_effects, influence_func_info, df, unit, precomputed
2195
2271
  )
2196
- if overall_effective_df is not None:
2197
- df_survey = overall_effective_df
2272
+ if overall_effective_df is not None and df_survey is not None:
2273
+ df_survey = min(int(df_survey), int(overall_effective_df))
2274
+ # Propagate to survey_metadata for display consistency (CS
2275
+ # parity) — the capped value, never the sentinel below.
2276
+ if survey_metadata is not None:
2277
+ survey_metadata.df_survey = df_survey
2278
+ # Replicate design with undefined df (QR rank <= 1): df=0 sentinel
2279
+ # -> NaN inference, applied to the LOCAL df only (survey_metadata
2280
+ # keeps None).
2281
+ df_overall = df_survey
2282
+ if (
2283
+ df_survey is None
2284
+ and resolved_survey is not None
2285
+ and resolved_survey.uses_replicate_variance
2286
+ ):
2287
+ df_overall = 0
2198
2288
  overall_t_stat, overall_p_value, overall_conf_int = safe_inference(
2199
- overall_att, overall_se, alpha=self.alpha, df=df_survey
2289
+ overall_att, overall_se, alpha=self.alpha, df=df_overall
2200
2290
  )
2201
2291
 
2202
2292
  # Optional multiplier bootstrap (keyword form; aggregate=None is the
@@ -15,9 +15,12 @@ definition (REGISTRY.md "IF-based variance estimators..." — the default),
15
15
  and DMLDiD's augmented-score SE ``sqrt(mean(psi_bar**2)/n)`` is exactly
16
16
  that on NO-DESIGN fits — per UNIT on panel fits, per OBSERVATION on
17
17
  repeated-cross-section fits (rows are the sampling units there). Under a
18
- ``survey_design=``/``cluster=`` the per-cell SE is the design-based CR1 /
19
- weighted-IF variance instead (the CS clustered-``hc1`` convention:
20
- ``SurveyDesign(psu=...)`` routed through the shared stratified-PSU meat).
18
+ ``survey_design=``/``cluster=`` the per-cell SE is design-based instead:
19
+ full-design TSL fits use the CR1 / weighted-IF variance (the CS
20
+ clustered-``hc1`` convention: ``SurveyDesign(psu=...)`` routed through the
21
+ shared stratified-PSU meat), while replicate-weight fits use IF-reweighting
22
+ via ``compute_replicate_if_variance`` on the same per-cell payload
23
+ (``df = rank(replicate matrix) - 1``; REGISTRY DMLDiD Note).
21
24
  """
22
25
 
23
26
  from dataclasses import dataclass, field
@@ -1397,6 +1397,32 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
1397
1397
  apply_bootstrap_group_overrides(group_effects, bootstrap_results, self.alpha)
1398
1398
 
1399
1399
  # ----- Build results -----
1400
+ # Raw (pre-normalization) unit weights for the metadata recompute:
1401
+ # compute_survey_metadata expects the ORIGINAL scale (resolve()
1402
+ # rescales pweights to mean 1, so the resolved unit weights would
1403
+ # misreport sum_weights/weight_range; scale-invariant fields are
1404
+ # unaffected either way).
1405
+ raw_unit_w_meta: Optional[np.ndarray] = None
1406
+ if self._unit_resolved_survey is not None:
1407
+ assert survey_design is not None
1408
+ # `is not None`, not truthiness: resolve() treats any non-None
1409
+ # string — an empty-string column name included — as a column.
1410
+ raw_obs_w_meta = (
1411
+ data[survey_design.weights].values.astype(np.float64)
1412
+ if survey_design.weights is not None
1413
+ else np.ones(len(data), dtype=np.float64)
1414
+ )
1415
+ raw_unit_w_meta = raw_obs_w_meta[self._unit_first_panel_row]
1416
+ # Per-row ES df provenance (M-092 completion): the post-overall
1417
+ # survey-df snapshot is the df the ES rows' safe_inference used.
1418
+ # None when no ES surface was built, under bootstrap (percentile
1419
+ # inference used no df — the shipped producer convention), and for
1420
+ # the replicate-undefined 0 sentinel (representable only via
1421
+ # survey_metadata.df_survey).
1422
+ _es_df_final: Optional[float] = None
1423
+ if event_study_effects is not None and bootstrap_results is None:
1424
+ if _survey_df_post_overall is not None and _survey_df_post_overall > 0:
1425
+ _es_df_final = float(_survey_df_post_overall)
1400
1426
  self.results_ = EfficientDiDResults(
1401
1427
  group_time_effects=group_time_effects,
1402
1428
  overall_att=overall_att,
@@ -1416,6 +1442,7 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
1416
1442
  bootstrap_weights=self.bootstrap_weights,
1417
1443
  seed=self.seed,
1418
1444
  event_study_effects=event_study_effects,
1445
+ event_study_df=_es_df_final,
1419
1446
  group_effects=group_effects,
1420
1447
  efficient_weights=stored_weights if stored_weights else None,
1421
1448
  omega_condition_numbers=stored_cond if stored_cond else None,
@@ -1452,7 +1479,7 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
1452
1479
  kernel_bandwidth=self.kernel_bandwidth,
1453
1480
  omega_ridge=self.omega_ridge,
1454
1481
  survey_metadata=(
1455
- self._recompute_unit_survey_metadata(survey_metadata)
1482
+ self._recompute_unit_survey_metadata(survey_metadata, raw_unit_w_meta)
1456
1483
  if survey_metadata is not None
1457
1484
  else None
1458
1485
  ),
@@ -1483,14 +1510,22 @@ class EfficientDiD(EfficientDiDBootstrapMixin, _EfficientAggregationMixin, BaseE
1483
1510
  self.is_fitted_ = True
1484
1511
  return self.results_
1485
1512
 
1486
- def _recompute_unit_survey_metadata(self, panel_metadata):
1487
- """Recompute survey metadata from unit-level design if available."""
1513
+ def _recompute_unit_survey_metadata(self, panel_metadata, raw_unit_weights=None):
1514
+ """Recompute survey metadata from unit-level design if available.
1515
+
1516
+ ``raw_unit_weights`` carries the ORIGINAL-scale (pre-normalization)
1517
+ unit weights and MUST be passed whenever ``_unit_resolved_survey``
1518
+ is set — never fall back to ``_unit_resolved_survey.weights``, which
1519
+ resolve() rescaled to mean 1 and would misreport sum_weights/
1520
+ weight_range (scale-invariant fields are unaffected either way).
1521
+ """
1488
1522
  if self._unit_resolved_survey is not None:
1489
1523
  from diff_diff.survey import compute_survey_metadata
1490
1524
 
1525
+ assert raw_unit_weights is not None
1491
1526
  meta = compute_survey_metadata(
1492
1527
  self._unit_resolved_survey,
1493
- self._unit_resolved_survey.weights,
1528
+ raw_unit_weights,
1494
1529
  )
1495
1530
  # Propagate effective replicate df if available
1496
1531
  # (but not the df=0 sentinel — keep metadata as None for undefined df)