diff-diff 3.6.1__tar.gz → 3.6.2__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 (98) hide show
  1. {diff_diff-3.6.1 → diff_diff-3.6.2}/PKG-INFO +1 -1
  2. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/__init__.py +1 -1
  3. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/_backend.py +12 -0
  4. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/_nprobust_port.py +13 -0
  5. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/_reporting_helpers.py +20 -0
  6. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/bacon.py +15 -0
  7. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/continuous_did.py +46 -81
  8. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/diagnostic_report.py +181 -7
  9. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did.py +4 -23
  10. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did_covariates.py +33 -10
  11. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/estimators.py +81 -6
  12. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/llms-full.txt +7 -7
  13. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/llms.txt +2 -2
  14. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/had.py +89 -44
  15. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/linalg.py +21 -4
  16. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/results.py +5 -3
  17. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/stacked_did.py +4 -0
  18. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/sun_abraham.py +16 -14
  19. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/survey.py +65 -2
  20. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/synthetic_control.py +198 -21
  21. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/synthetic_control_results.py +717 -72
  22. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/twfe.py +29 -35
  23. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/utils.py +436 -69
  24. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/wooldridge.py +21 -1
  25. {diff_diff-3.6.1 → diff_diff-3.6.2}/pyproject.toml +1 -1
  26. {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/Cargo.lock +8 -8
  27. {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/Cargo.toml +5 -1
  28. diff_diff-3.6.2/rust/src/alloc_profile.rs +70 -0
  29. diff_diff-3.6.2/rust/src/demean.rs +333 -0
  30. {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/lib.rs +17 -1
  31. {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/linalg.rs +70 -48
  32. {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/trop.rs +212 -87
  33. {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/weights.rs +227 -61
  34. {diff_diff-3.6.1 → diff_diff-3.6.2}/LICENSE +0 -0
  35. {diff_diff-3.6.1 → diff_diff-3.6.2}/README.md +0 -0
  36. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/_guides_api.py +0 -0
  37. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/agent_workflow.py +0 -0
  38. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/balancing.py +0 -0
  39. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/bootstrap_chunking.py +0 -0
  40. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/bootstrap_utils.py +0 -0
  41. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/business_report.py +0 -0
  42. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille.py +0 -0
  43. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
  44. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
  45. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/conformal.py +0 -0
  46. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/conley.py +0 -0
  47. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/continuous_did_bspline.py +0 -0
  48. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/continuous_did_results.py +0 -0
  49. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/datasets.py +0 -0
  50. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/diagnostics.py +0 -0
  51. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did_bootstrap.py +0 -0
  52. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did_results.py +0 -0
  53. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did_weights.py +0 -0
  54. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/__init__.py +0 -0
  55. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/llms-autonomous.txt +0 -0
  56. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/llms-practitioner.txt +0 -0
  57. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/had_pretests.py +0 -0
  58. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/honest_did.py +0 -0
  59. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/imputation.py +0 -0
  60. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/imputation_bootstrap.py +0 -0
  61. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/imputation_results.py +0 -0
  62. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/local_linear.py +0 -0
  63. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/lpdid.py +0 -0
  64. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/lpdid_results.py +0 -0
  65. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/power.py +0 -0
  66. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/practitioner.py +0 -0
  67. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/prep.py +0 -0
  68. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/prep_dgp.py +0 -0
  69. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/pretrends.py +0 -0
  70. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/profile.py +0 -0
  71. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/spillover.py +0 -0
  72. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/stacked_did_results.py +0 -0
  73. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered.py +0 -0
  74. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_aggregation.py +0 -0
  75. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_bootstrap.py +0 -0
  76. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_results.py +0 -0
  77. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_triple_diff.py +0 -0
  78. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_triple_diff_results.py +0 -0
  79. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/synthetic_did.py +0 -0
  80. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/triple_diff.py +0 -0
  81. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/trop.py +0 -0
  82. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/trop_global.py +0 -0
  83. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/trop_local.py +0 -0
  84. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/trop_results.py +0 -0
  85. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/two_stage.py +0 -0
  86. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/two_stage_bootstrap.py +0 -0
  87. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/two_stage_results.py +0 -0
  88. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/__init__.py +0 -0
  89. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_common.py +0 -0
  90. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_continuous.py +0 -0
  91. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_diagnostic.py +0 -0
  92. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_event_study.py +0 -0
  93. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_power.py +0 -0
  94. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_staggered.py +0 -0
  95. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_synthetic.py +0 -0
  96. {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/wooldridge_results.py +0 -0
  97. {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/build.rs +0 -0
  98. {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/bootstrap.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 3.6.1
3
+ Version: 3.6.2
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: OS Independent
@@ -301,7 +301,7 @@ ETWFE = WooldridgeDiD
301
301
  DCDH = ChaisemartinDHaultfoeuille
302
302
  HAD = HeterogeneousAdoptionDiD
303
303
 
304
- __version__ = "3.6.1"
304
+ __version__ = "3.6.2"
305
305
  __all__ = [
306
306
  # Estimators
307
307
  "DifferenceInDifferences",
@@ -65,6 +65,14 @@ except ImportError:
65
65
  _rust_sc_weight_fw_weighted_with_convergence = None
66
66
  _rust_backend_info = None
67
67
 
68
+ # FE-absorption MAP demeaning kernel: imported independently so a stale or
69
+ # mixed-version extension missing only this newer symbol degrades to the
70
+ # numpy demeaning engine WITHOUT disabling the older Rust accelerations.
71
+ try:
72
+ from diff_diff._rust_backend import demean_map as _rust_demean_map
73
+ except ImportError:
74
+ _rust_demean_map = None
75
+
68
76
  # Determine final backend based on environment variable and availability
69
77
  if _backend_env == "python":
70
78
  # Force pure Python mode - disable Rust even if available
@@ -73,6 +81,8 @@ if _backend_env == "python":
73
81
  _rust_project_simplex = None
74
82
  _rust_solve_ols = None
75
83
  _rust_compute_robust_vcov = None
84
+ # FE-absorption MAP demeaning kernel
85
+ _rust_demean_map = None
76
86
  # TROP estimator acceleration (local method)
77
87
  _rust_unit_distance_matrix = None
78
88
  _rust_loocv_grid_search = None
@@ -124,6 +134,8 @@ __all__ = [
124
134
  "_rust_project_simplex",
125
135
  "_rust_solve_ols",
126
136
  "_rust_compute_robust_vcov",
137
+ # FE-absorption MAP demeaning kernel
138
+ "_rust_demean_map",
127
139
  # TROP estimator acceleration (local method)
128
140
  "_rust_unit_distance_matrix",
129
141
  "_rust_loocv_grid_search",
@@ -1361,6 +1361,19 @@ def lprobust(
1361
1361
  se_cl = float(np.sqrt((deriv_fact**2) * V_Y_cl[deriv, deriv]))
1362
1362
  se_rb = float(np.sqrt((deriv_fact**2) * V_Y_bc[deriv, deriv]))
1363
1363
 
1364
+ # Cluster-robust variance is unidentified when fewer than two clusters
1365
+ # contribute to the ACTIVE kernel window (``eC = cluster[ind]``): the
1366
+ # between-cluster meat is degenerate, so a finite ``se`` here would report
1367
+ # unidentified clustered inference as if identified. NaN both SEs so any
1368
+ # downstream inference (the ``safe_inference`` gate in
1369
+ # ``bias_corrected_local_linear``; HAD's beta-scale rescale) is NaN-coupled.
1370
+ # Unclustered fits (``eC is None``) are unaffected, and a clustered window
1371
+ # with >= 2 distinct clusters is bit-identical, so the DGP-4 golden parity
1372
+ # is preserved.
1373
+ if eC is not None and len(np.unique(eC)) < 2:
1374
+ se_cl = float("nan")
1375
+ se_rb = float("nan")
1376
+
1364
1377
  # --- Per-observation influence function for the BIAS-CORRECTED point
1365
1378
  # estimate at ``deriv`` (Phase 4.5 survey composition).
1366
1379
  # Aligned with ``V_Y_bc`` (NOT ``V_Y_cl``) so survey-composed variance
@@ -635,6 +635,26 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
635
635
  "reference": "REGISTRY.md Sec. SyntheticControl",
636
636
  }
637
637
 
638
+ if name == "SpilloverDiDResults":
639
+ return {
640
+ "name": "total effect on the treated (Butts spillover-aware ATT)",
641
+ "definition": (
642
+ "The total effect on the treated ``tau_total`` from Butts (2021) "
643
+ "ring-indicator spillover DiD, identified off FAR-AWAY control "
644
+ "observations (``d_it > d_bar``, Assumption 5) rather than any "
645
+ "not-yet-/never-treated pool. The estimator decomposes into the "
646
+ "DIRECT effect on treated units plus per-ring spillover-on-control "
647
+ "effects that relax SUTVA within the treated units' spatial "
648
+ "neighborhood; ``att`` is the headline total effect, while the "
649
+ "per-ring ``spillover_effects`` and (when ``event_study=True``) the "
650
+ "per-event-time direct dynamics are available on the result object "
651
+ "for disaggregated inference."
652
+ ),
653
+ "aggregation": "spillover",
654
+ "headline_attribute": "att",
655
+ "reference": "Butts (2021); REGISTRY.md Sec. SpilloverDiD",
656
+ }
657
+
638
658
  # Default: unrecognized result class. Fall through with a neutral
639
659
  # block — agents / downstream consumers can still dispatch on
640
660
  # ``aggregation="unknown"`` and fall back to generic ATT narration.
@@ -18,6 +18,7 @@ import numpy as np
18
18
  import pandas as pd
19
19
 
20
20
  from diff_diff.results import _format_survey_block
21
+ from diff_diff.utils import pre_demean_norms, snap_absorbed_regressors
21
22
  from diff_diff.utils import within_transform as _within_transform_util
22
23
 
23
24
 
@@ -795,6 +796,7 @@ class BaconDecomposition:
795
796
  ) -> float:
796
797
  """Compute TWFE estimate using within-transformation."""
797
798
  # Apply two-way within transformation (weighted if survey weights provided)
799
+ _pre_norms = pre_demean_norms(df, [treat_col], weights=weights)
798
800
  df_dm = _within_transform_util(
799
801
  df,
800
802
  [outcome, treat_col],
@@ -803,6 +805,19 @@ class BaconDecomposition:
803
805
  suffix="_within",
804
806
  weights=weights,
805
807
  )
808
+ # Snap an FE-spanned treatment to exact zero: the d_var == 0 guard
809
+ # below then returns its deterministic 0.0 (with the cause warning)
810
+ # instead of an arbitrary junk/junk division.
811
+ snap_absorbed_regressors(
812
+ df_dm,
813
+ [treat_col],
814
+ _pre_norms,
815
+ absorbed_desc=f"unit '{unit}' and time '{time}' fixed effects",
816
+ group_vars=[unit, time],
817
+ suffix="_within",
818
+ display_names={treat_col: "treatment"},
819
+ weights=weights,
820
+ )
806
821
 
807
822
  # Extract within-transformed values
808
823
  y_within = df_dm[f"{outcome}_within"].values
@@ -31,9 +31,9 @@ from diff_diff.continuous_did_results import (
31
31
  )
32
32
  from diff_diff.linalg import _rank_guarded_inv, solve_ols
33
33
  from diff_diff.survey import (
34
- ResolvedSurveyDesign,
35
34
  _resolve_survey_for_fit,
36
35
  _validate_unit_constant_survey,
36
+ build_unit_first_row_index,
37
37
  compute_survey_vcov,
38
38
  )
39
39
  from diff_diff.utils import safe_inference
@@ -413,8 +413,7 @@ class ContinuousDiD:
413
413
 
414
414
  # Filter out NaN cells (e.g., from zero effective survey mass)
415
415
  gt_results = {
416
- gt: r for gt, r in gt_results.items()
417
- if np.isfinite(r.get("att_glob", np.nan))
416
+ gt: r for gt, r in gt_results.items() if np.isfinite(r.get("att_glob", np.nan))
418
417
  }
419
418
 
420
419
  if len(gt_results) == 0:
@@ -573,9 +572,12 @@ class ContinuousDiD:
573
572
  # Survey df for t-distribution inference (unit-level, not panel-level)
574
573
  _survey_df = analytic.get("df_survey")
575
574
  # Guard: replicate design with undefined df → NaN inference
576
- if (_survey_df is None and resolved_survey is not None
577
- and hasattr(resolved_survey, 'uses_replicate_variance')
578
- and resolved_survey.uses_replicate_variance):
575
+ if (
576
+ _survey_df is None
577
+ and resolved_survey is not None
578
+ and hasattr(resolved_survey, "uses_replicate_variance")
579
+ and resolved_survey.uses_replicate_variance
580
+ ):
579
581
  _survey_df = 0
580
582
 
581
583
  # Recompute survey_metadata from unit-level design so reported
@@ -589,8 +591,7 @@ class ContinuousDiD:
589
591
 
590
592
  # Propagate replicate df override to survey_metadata for display
591
593
  # (but not the df=0 sentinel — keep metadata as None for undefined df)
592
- if (_survey_df is not None and _survey_df != 0
593
- and survey_metadata is not None):
594
+ if _survey_df is not None and _survey_df != 0 and survey_metadata is not None:
594
595
  if survey_metadata.df_survey != _survey_df:
595
596
  survey_metadata.df_survey = _survey_df
596
597
 
@@ -624,30 +625,8 @@ class ContinuousDiD:
624
625
  unit_resolved_es = None
625
626
  if resolved_survey is not None:
626
627
  row_idx = precomp["unit_first_panel_row"]
627
- uw = (
628
- precomp.get("unit_survey_weights")
629
- if precomp.get("unit_survey_weights") is not None
630
- else np.ones(n_units)
631
- )
632
- us = (
633
- resolved_survey.strata[row_idx]
634
- if resolved_survey.strata is not None
635
- else None
636
- )
637
- up = (
638
- resolved_survey.psu[row_idx]
639
- if resolved_survey.psu is not None
640
- else None
641
- )
642
- uf = (
643
- resolved_survey.fpc[row_idx]
644
- if resolved_survey.fpc is not None
645
- else None
646
- )
647
- n_strata_u = len(np.unique(us)) if us is not None else 0
648
- n_psu_u = len(np.unique(up)) if up is not None else 0
649
- unit_resolved_es = resolved_survey.subset_to_units(
650
- row_idx, uw, us, up, uf, n_strata_u, n_psu_u,
628
+ unit_resolved_es = resolved_survey.subset_to_units_by_row_idx(
629
+ row_idx, unit_weights=precomp.get("unit_survey_weights")
651
630
  )
652
631
 
653
632
  for e_val, info_e in event_study_effects.items():
@@ -711,13 +690,21 @@ class ContinuousDiD:
711
690
 
712
691
  # Score-scale: psi = w * if_es (matches TSL bread)
713
692
  psi_es = unit_resolved_es.weights * if_es
714
- variance, _nv = compute_replicate_if_variance(psi_es, unit_resolved_es)
715
- es_se = float(np.sqrt(max(variance, 0.0))) if np.isfinite(variance) else np.nan
693
+ variance, _nv = compute_replicate_if_variance(
694
+ psi_es, unit_resolved_es
695
+ )
696
+ es_se = (
697
+ float(np.sqrt(max(variance, 0.0)))
698
+ if np.isfinite(variance)
699
+ else np.nan
700
+ )
716
701
  else:
717
702
  X_ones_es = np.ones((n_units, 1))
718
703
  tsl_scale_es = float(unit_resolved_es.weights.sum())
719
704
  if_es_tsl = if_es * tsl_scale_es
720
- vcov_es = compute_survey_vcov(X_ones_es, if_es_tsl, unit_resolved_es)
705
+ vcov_es = compute_survey_vcov(
706
+ X_ones_es, if_es_tsl, unit_resolved_es
707
+ )
721
708
  es_se = float(np.sqrt(np.abs(vcov_es[0, 0])))
722
709
  else:
723
710
  es_se = float(np.sqrt(np.sum(if_es**2)))
@@ -831,15 +818,11 @@ class ContinuousDiD:
831
818
  unit_cohorts[i] = unit_first.loc[u, first_treat]
832
819
  dose_vector[i] = unit_first.loc[u, dose]
833
820
 
834
- # Build unit-to-first-panel-row mapping (for subsetting panel-level arrays)
835
- # This maps each unit index to the positional index of its first row in df.
836
- unit_first_panel_row = np.zeros(n_units, dtype=int)
837
- seen_units: set = set()
838
- for pos_idx, (_, row) in enumerate(df.iterrows()):
839
- u = row[unit]
840
- if u not in seen_units:
841
- seen_units.add(u)
842
- unit_first_panel_row[unit_to_idx[u]] = pos_idx
821
+ # Build unit-to-first-panel-row mapping (for subsetting panel-level
822
+ # arrays): the positional index of each unit's first row in df, aligned
823
+ # to ``all_units`` (== ``unit_to_idx`` order since
824
+ # ``unit_to_idx = {u: i for i, u in enumerate(all_units)}``).
825
+ unit_first_panel_row = build_unit_first_row_index(df[unit].values, all_units)
843
826
 
844
827
  # Per-unit survey weights (take first obs per unit from panel data)
845
828
  unit_survey_weights = None
@@ -949,8 +932,10 @@ class ContinuousDiD:
949
932
  # Guard against zero effective mass (e.g., after subpopulation)
950
933
  if np.sum(w_treated) <= 0 or np.sum(w_control) <= 0:
951
934
  return {
952
- "att_glob": np.nan, "acrt_glob": np.nan,
953
- "n_treated": 0, "n_control": 0,
935
+ "att_glob": np.nan,
936
+ "acrt_glob": np.nan,
937
+ "n_treated": 0,
938
+ "n_control": 0,
954
939
  "att_d": np.full(len(dvals), np.nan),
955
940
  "acrt_d": np.full(len(dvals), np.nan),
956
941
  }
@@ -1293,23 +1278,8 @@ class ContinuousDiD:
1293
1278
  # but influence functions are unit-level (n_units). Build a unit-level
1294
1279
  # ResolvedSurveyDesign by subsetting to one obs per unit.
1295
1280
  row_idx = precomp["unit_first_panel_row"]
1296
- unit_weights = precomp.get("unit_survey_weights")
1297
- if unit_weights is None:
1298
- unit_weights = np.ones(n_units)
1299
-
1300
- unit_strata = (
1301
- resolved_survey.strata[row_idx] if resolved_survey.strata is not None else None
1302
- )
1303
- unit_psu = resolved_survey.psu[row_idx] if resolved_survey.psu is not None else None
1304
- unit_fpc = resolved_survey.fpc[row_idx] if resolved_survey.fpc is not None else None
1305
-
1306
- # Count unique strata/PSU in the unit-level subset
1307
- n_strata_unit = len(np.unique(unit_strata)) if unit_strata is not None else 0
1308
- n_psu_unit = len(np.unique(unit_psu)) if unit_psu is not None else 0
1309
-
1310
- unit_resolved = resolved_survey.subset_to_units(
1311
- row_idx, unit_weights, unit_strata, unit_psu, unit_fpc,
1312
- n_strata_unit, n_psu_unit,
1281
+ unit_resolved = resolved_survey.subset_to_units_by_row_idx(
1282
+ row_idx, unit_weights=precomp.get("unit_survey_weights")
1313
1283
  )
1314
1284
 
1315
1285
  X_ones = np.ones((n_units, 1))
@@ -1370,7 +1340,11 @@ class ContinuousDiD:
1370
1340
 
1371
1341
  # Return unit-level survey df and resolved design for metadata recomputation
1372
1342
  # Only override with n_valid-based df when replicates were actually dropped
1373
- if resolved_survey is not None and hasattr(resolved_survey, 'uses_replicate_variance') and resolved_survey.uses_replicate_variance:
1343
+ if (
1344
+ resolved_survey is not None
1345
+ and hasattr(resolved_survey, "uses_replicate_variance")
1346
+ and resolved_survey.uses_replicate_variance
1347
+ ):
1374
1348
  if _rep_n_valid < unit_resolved.n_replicates:
1375
1349
  unit_df_survey = _rep_n_valid - 1 if _rep_n_valid > 1 else None
1376
1350
  else:
@@ -1415,7 +1389,11 @@ class ContinuousDiD:
1415
1389
 
1416
1390
  # Reject replicate-weight designs for bootstrap — replicate variance
1417
1391
  # is an analytical alternative to bootstrap, not compatible with it
1418
- if resolved_survey is not None and hasattr(resolved_survey, "uses_replicate_variance") and resolved_survey.uses_replicate_variance:
1392
+ if (
1393
+ resolved_survey is not None
1394
+ and hasattr(resolved_survey, "uses_replicate_variance")
1395
+ and resolved_survey.uses_replicate_variance
1396
+ ):
1419
1397
  raise NotImplementedError(
1420
1398
  "ContinuousDiD bootstrap (n_bootstrap > 0) is not supported "
1421
1399
  "with replicate-weight survey designs. Replicate weights provide "
@@ -1429,22 +1407,9 @@ class ContinuousDiD:
1429
1407
  # Build unit-level ResolvedSurveyDesign for survey-aware bootstrap
1430
1408
  unit_resolved = None
1431
1409
  if resolved_survey is not None:
1432
- from diff_diff.survey import ResolvedSurveyDesign
1433
-
1434
1410
  row_idx = precomp["unit_first_panel_row"]
1435
- unit_weights = precomp.get("unit_survey_weights")
1436
- if unit_weights is None:
1437
- unit_weights = np.ones(n_units)
1438
- unit_strata = (
1439
- resolved_survey.strata[row_idx] if resolved_survey.strata is not None else None
1440
- )
1441
- unit_psu = resolved_survey.psu[row_idx] if resolved_survey.psu is not None else None
1442
- unit_fpc = resolved_survey.fpc[row_idx] if resolved_survey.fpc is not None else None
1443
- n_strata_u = len(np.unique(unit_strata)) if unit_strata is not None else 0
1444
- n_psu_u = len(np.unique(unit_psu)) if unit_psu is not None else 0
1445
- unit_resolved = resolved_survey.subset_to_units(
1446
- row_idx, unit_weights, unit_strata, unit_psu, unit_fpc,
1447
- n_strata_u, n_psu_u,
1411
+ unit_resolved = resolved_survey.subset_to_units_by_row_idx(
1412
+ row_idx, unit_weights=precomp.get("unit_survey_weights")
1448
1413
  )
1449
1414
 
1450
1415
  # Generate bootstrap weights — PSU-level when survey design is present
@@ -1682,7 +1647,7 @@ class ContinuousDiD:
1682
1647
  boot_es[e],
1683
1648
  alpha=self.alpha,
1684
1649
  context=f"event study e={e}",
1685
- )
1650
+ )
1686
1651
  es_se[e] = se_e
1687
1652
  es_ci[e] = ci_e
1688
1653
  es_p[e] = p_e
@@ -65,8 +65,8 @@ _CHECK_NAMES: Tuple[str, ...] = (
65
65
  "placebo",
66
66
  )
67
67
 
68
- # Type-level applicability: which checks are *ever* applicable for each of the
69
- # 16 result types. Instance-level applicability further filters by whether
68
+ # Type-level applicability: which checks are *ever* applicable for each
69
+ # registered result type. Instance-level applicability further filters by whether
70
70
  # required attributes are present (e.g. ``survey_metadata`` for DEFF) and by
71
71
  # whether the user disabled a check via ``run_*=False``.
72
72
  # See ``docs/methodology/REPORTING.md`` for the full matrix and rationale.
@@ -74,7 +74,7 @@ _CHECK_NAMES: Tuple[str, ...] = (
74
74
  # Implementation note: The keys are result-class names looked up via
75
75
  # ``type(results).__name__``. This string-based dispatch mirrors the
76
76
  # ``_HANDLERS`` pattern in ``diff_diff/practitioner.py`` and avoids circular
77
- # imports across the 16 result modules. Renaming or aliasing any result class
77
+ # imports across the result modules. Renaming or aliasing any result class
78
78
  # requires updating both this table and ``_PT_METHOD`` below; the
79
79
  # applicability-matrix test parametrized over all result types serves as the
80
80
  # regression guard.
@@ -131,6 +131,27 @@ _APPLICABILITY: Dict[str, FrozenSet[str]] = {
131
131
  "heterogeneity",
132
132
  }
133
133
  ),
134
+ "SpilloverDiDResults": frozenset(
135
+ # Butts (2021) ring-indicator spillover DiD is a two-stage-GMM
136
+ # estimator, so it inherits TwoStage's diagnostic set MINUS
137
+ # ``bacon``. ``bacon`` is excluded because SpilloverDiD identifies
138
+ # the direct effect off FAR-AWAY units (Butts Assumption 5), not
139
+ # off the TWFE 2x2 comparisons a Goodman-Bacon decomposition
140
+ # enumerates: ``bacon_decompose`` on the raw binary treatment
141
+ # ignores the ring/distance structure and would pool spillover-
142
+ # contaminated in-ring units into the control group — the exact
143
+ # SUTVA violation the estimator exists to handle (same rationale
144
+ # that excludes bacon for SyntheticControl / TROP / Continuous).
145
+ # ``parallel_trends`` routes to ``event_study`` on the per-event-
146
+ # time DIRECT-effect dynamics (populated when ``event_study=True``);
147
+ # ``design_effect`` is instance-gated on ``survey_metadata`` (Wave
148
+ # E.1); ``heterogeneity`` reads ``event_study_effects``.
149
+ {
150
+ "parallel_trends",
151
+ "design_effect",
152
+ "heterogeneity",
153
+ }
154
+ ),
134
155
  "StackedDiDResults": frozenset(
135
156
  {
136
157
  "parallel_trends",
@@ -218,6 +239,7 @@ _PT_METHOD: Dict[str, str] = {
218
239
  "SunAbrahamResults": "event_study",
219
240
  "ImputationDiDResults": "event_study",
220
241
  "TwoStageDiDResults": "event_study",
242
+ "SpilloverDiDResults": "event_study",
221
243
  "StackedDiDResults": "event_study",
222
244
  "EfficientDiDResults": "hausman",
223
245
  "ContinuousDiDResults": "event_study",
@@ -263,7 +285,7 @@ class DiagnosticReport:
263
285
  ----------
264
286
  results : Any
265
287
  A fitted diff-diff results object (e.g. ``CallawaySantAnnaResults``,
266
- ``DiDResults``, ``SyntheticDiDResults``). Any of the 16 result types
288
+ ``DiDResults``, ``SyntheticDiDResults``). Any registered result type
267
289
  in the library is accepted.
268
290
  data : pandas.DataFrame, optional
269
291
  The underlying panel. Required for checks that need raw data
@@ -703,6 +725,19 @@ class DiagnosticReport:
703
725
  # summary emits the "inconclusive" identifying-
704
726
  # assumption warning rather than silently dropping PT.
705
727
  if not pre_coefs and n_dropped_undefined == 0:
728
+ # SpilloverDiD's event-study switch is the
729
+ # ``SpilloverDiD(..., event_study=True)`` constructor
730
+ # kwarg, not the ``aggregate='event_study'`` argument
731
+ # the generic staggered-estimator message points at
732
+ # (SpilloverDiD has no ``aggregate`` kwarg). Emit an
733
+ # estimator-accurate remediation for this family.
734
+ if name == "SpilloverDiDResults":
735
+ return (
736
+ "No pre-period event-study coefficients are exposed "
737
+ "on this fit. Re-fit with "
738
+ "SpilloverDiD(..., event_study=True) to populate the "
739
+ "per-event-time direct-effect output."
740
+ )
706
741
  return (
707
742
  "No pre-period event-study coefficients are exposed on "
708
743
  "this fit. For staggered estimators, re-fit with "
@@ -2287,6 +2322,7 @@ class DiagnosticReport:
2287
2322
  "rmspe_ratio": _to_python_float(getattr(r, "rmspe_ratio", None)),
2288
2323
  "n_placebos": _to_python_scalar(n_placebos),
2289
2324
  "n_failed": _to_python_scalar(getattr(r, "n_failed", None)),
2325
+ "n_infeasible": _to_python_scalar(getattr(r, "n_infeasible", None)),
2290
2326
  }
2291
2327
  # Distinguish a valid run from an attempted-but-infeasible one so BR/DR
2292
2328
  # consumers see an explicit status/reason rather than a bare NaN p-value.
@@ -2312,15 +2348,35 @@ class DiagnosticReport:
2312
2348
  "all_placebos_failed": (
2313
2349
  "in_space_placebo() was run but every donor refit failed to "
2314
2350
  "converge, so no placebo entered the reference set; "
2315
- "placebo_p_value is NaN."
2351
+ "placebo_p_value is NaN. Raise n_starts or loosen the "
2352
+ "optimizer tolerances."
2353
+ ),
2354
+ "all_placebos_infeasible": (
2355
+ "in_space_placebo() was run but every donor refit was "
2356
+ "structurally infeasible under v_method='cv' (the "
2357
+ "pseudo-treated donor pool is indistinguishable in a "
2358
+ "re-aggregated CV window), so no placebo entered the reference "
2359
+ "set; placebo_p_value is NaN. Adjust the predictors, v_cv_t0, "
2360
+ "or the donor pool."
2361
+ ),
2362
+ "all_placebos_unusable": (
2363
+ "in_space_placebo() was run but no donor refit was usable: "
2364
+ "some failed to converge AND some were structurally infeasible "
2365
+ "(see n_failed / n_infeasible); placebo_p_value is NaN."
2316
2366
  ),
2317
2367
  }
2318
2368
  block["status"] = "infeasible"
2369
+ # Machine-readable code distinguishing a solver convergence failure
2370
+ # ("all_placebos_failed") from structural infeasibility
2371
+ # ("all_placebos_infeasible" / "too_few_donors") or a mix
2372
+ # ("all_placebos_unusable"), without parsing `reason`.
2373
+ block["reason_code"] = placebo_status
2319
2374
  block["reason"] = _reasons.get(
2320
2375
  placebo_status,
2321
2376
  "in_space_placebo() was run but produced no valid reference set "
2322
2377
  "(fewer than 2 donors, a non-converged treated fit, or all donor "
2323
- "refits failed); placebo_p_value is NaN.",
2378
+ "refits failed / were structurally infeasible); placebo_p_value is "
2379
+ "NaN.",
2324
2380
  )
2325
2381
  out["in_space_placebo"] = block
2326
2382
  else:
@@ -2353,6 +2409,7 @@ class DiagnosticReport:
2353
2409
  else None
2354
2410
  ),
2355
2411
  "n_failed": _to_python_scalar(getattr(r, "_loo_n_failed", None)),
2412
+ "n_infeasible": _to_python_scalar(getattr(r, "_loo_n_infeasible", None)),
2356
2413
  }
2357
2414
  else:
2358
2415
  _loo_reasons = {
@@ -2371,13 +2428,29 @@ class DiagnosticReport:
2371
2428
  "(see the status='failed' rows); raise n_starts or loosen the "
2372
2429
  "optimizer tolerances."
2373
2430
  ),
2431
+ "all_refits_infeasible": (
2432
+ "leave_one_out() was run but every donor-drop refit was "
2433
+ "structurally infeasible under v_method='cv' (the reduced donor "
2434
+ "pool is indistinguishable in a re-aggregated CV window; see the "
2435
+ "status='infeasible' rows); adjust the predictors, v_cv_t0, or "
2436
+ "the donor pool."
2437
+ ),
2438
+ "all_refits_unusable": (
2439
+ "leave_one_out() was run but no donor-drop refit was usable: "
2440
+ "some failed to converge AND some were structurally infeasible "
2441
+ "(see n_failed / n_infeasible)."
2442
+ ),
2374
2443
  }
2375
2444
  out["leave_one_out"] = {
2376
2445
  "status": "infeasible",
2377
2446
  # Machine-readable code so consumers can distinguish a numerical
2378
2447
  # convergence failure ("all_refits_failed") from structural
2379
- # infeasibility ("too_few_donors") without parsing `reason`.
2448
+ # infeasibility ("all_refits_infeasible" / "too_few_donors") or a
2449
+ # mix ("all_refits_unusable") without parsing `reason`. The n_failed
2450
+ # / n_infeasible counts give the exact breakdown.
2380
2451
  "reason_code": loo_status,
2452
+ "n_failed": _to_python_scalar(getattr(r, "_loo_n_failed", None)),
2453
+ "n_infeasible": _to_python_scalar(getattr(r, "_loo_n_infeasible", None)),
2381
2454
  "reason": _loo_reasons.get(
2382
2455
  loo_status, "leave_one_out() produced no valid refits."
2383
2456
  ),
@@ -2459,6 +2532,107 @@ class DiagnosticReport:
2459
2532
  ),
2460
2533
  }
2461
2534
 
2535
+ # Regression-weight extrapolation diagnostic (ADH 2015 §4): opt-in, surfaced once
2536
+ # the user has run results.regression_weights() (pure linear algebra — no refits).
2537
+ if getattr(r, "_regw_df", None) is not None:
2538
+ regw_status = getattr(r, "_regw_status", None)
2539
+ if regw_status == "ran":
2540
+ out["regression_weights"] = {
2541
+ "status": "ran",
2542
+ # Headline: how many donors an OLS/regression counterfactual would push
2543
+ # outside [0,1] (extrapolate). The SC simplex never does.
2544
+ "n_extrapolating": _to_python_scalar(getattr(r, "_regw_n_extrapolating", None)),
2545
+ # True if W^reg is a non-unique min-norm solution (not full row rank);
2546
+ # weight_sum then need not equal 1.
2547
+ "rank_deficient": bool(getattr(r, "_regw_rank_deficient", False)),
2548
+ "weight_sum": _to_python_float(getattr(r, "_regw_weight_sum", None)),
2549
+ }
2550
+ else:
2551
+ _regw_reasons = {
2552
+ "treated_fit_nonconverged": (
2553
+ "regression_weights() was run but the treated unit's own SCM fit "
2554
+ "did not converge at fit time, so the synthetic control it is "
2555
+ "compared against is not a valid optimum."
2556
+ ),
2557
+ "too_few_donors": (
2558
+ "regression_weights() was run but fewer than 2 donors are available "
2559
+ "(W^reg is trivially [1] with a single donor)."
2560
+ ),
2561
+ }
2562
+ out["regression_weights"] = {
2563
+ "status": "infeasible",
2564
+ "reason_code": regw_status,
2565
+ "reason": _regw_reasons.get(
2566
+ regw_status, "regression_weights() produced no table."
2567
+ ),
2568
+ }
2569
+ else:
2570
+ out["regression_weights"] = {
2571
+ "status": "not_run",
2572
+ "reason": (
2573
+ "Call results.regression_weights() to flag donors an OLS/regression "
2574
+ "counterfactual would weight outside [0,1] (ADH 2015 §4 extrapolation "
2575
+ "diagnostic; opt-in, no refit)."
2576
+ ),
2577
+ }
2578
+
2579
+ # Sparse-SC subset search (ADH 2015 §4): opt-in, surfaced once the user has run
2580
+ # results.sparse_synthetic_control() (exhaustive subset search, V held fixed).
2581
+ if getattr(r, "_sparse_df", None) is not None:
2582
+ sparse_status = getattr(r, "_sparse_status", None)
2583
+ if sparse_status == "ran":
2584
+ # Compact per-size summary: how far the ATT / fit move as the synthetic is
2585
+ # forced sparse (the baseline row is dropped — it is the full-fit reference).
2586
+ records = r._sparse_df.to_dict("records")
2587
+ per_size = [
2588
+ {
2589
+ "size": _to_python_scalar(rec["size"]),
2590
+ "att": _to_python_float(rec["att"]),
2591
+ "delta_att": _to_python_float(rec["delta_att"]),
2592
+ "pre_rmspe": _to_python_float(rec["pre_rmspe"]),
2593
+ "n_failed": _to_python_scalar(rec["n_failed"]),
2594
+ "status": rec["status"],
2595
+ }
2596
+ for rec in records
2597
+ if rec["status"] != "baseline"
2598
+ ]
2599
+ out["sparse_synthetic_control"] = {
2600
+ "status": "ran",
2601
+ # Headline: the largest baseline-relative ATT swing across searched sizes.
2602
+ "max_abs_delta_att": _to_python_float(
2603
+ getattr(r, "_sparse_max_abs_delta_att", None)
2604
+ ),
2605
+ "sizes": per_size,
2606
+ }
2607
+ else:
2608
+ _sparse_reasons = {
2609
+ "treated_fit_nonconverged": (
2610
+ "sparse_synthetic_control() was run but the treated unit's own SCM "
2611
+ "fit did not converge at fit time, so the baseline ATT is not a "
2612
+ "valid reference for the sparse deltas."
2613
+ ),
2614
+ "too_few_donors": (
2615
+ "sparse_synthetic_control() was run but fewer than 2 donors are "
2616
+ "available (a sparse subset must be smaller than the pool)."
2617
+ ),
2618
+ }
2619
+ out["sparse_synthetic_control"] = {
2620
+ "status": "infeasible",
2621
+ "reason_code": sparse_status,
2622
+ "reason": _sparse_reasons.get(
2623
+ sparse_status, "sparse_synthetic_control() produced no valid subsets."
2624
+ ),
2625
+ }
2626
+ else:
2627
+ out["sparse_synthetic_control"] = {
2628
+ "status": "not_run",
2629
+ "reason": (
2630
+ "Call results.sparse_synthetic_control() to test how the fit / ATT "
2631
+ "degrade when the synthetic is forced to a few donors (ADH 2015 §4 "
2632
+ "sparse subset search; opt-in, V held fixed)."
2633
+ ),
2634
+ }
2635
+
2462
2636
  # Test-inversion confidence set (Firpo & Possebom 2018 §4): opt-in, surfaced once
2463
2637
  # the user has run results.confidence_set() (it reuses the in-space placebo
2464
2638
  # reference set — no refits). The analytical conf_int stays NaN; this is a SEPARATE