Internal tracking for technical debt, known limitations, and maintenance tasks.
For the public feature roadmap, see ROADMAP.md.
- Actionable Backlog — work with a clear implementation path and
no external blocker. Pull from here. Effort (
Quick≤1 day ·Mid3-10 CI rounds ·Heavyderivation-free but large) is noted per row;Priorityis carried from the originating PR review. - Deferred / Documented — known gaps that are not currently actionable: blocked on a methodology derivation, on external tooling (R / Stata / Julia) absent from CI, parked pending user demand / out of paper scope, or explicitly won't-fix. Retained for provenance and AI-review deviation-documentation — do not pull from here without first clearing the named blocker.
- Reference / Status — not backlog: user-facing limitations, module-size monitoring, deprecations, and current-state notes.
The Origin column (Actionable tables) and the PR column (Deferred tables) both point to the originating PR number or review tag.
| Issue | Location | Origin | Effort | Priority |
|---|---|---|---|---|
SyntheticControl conformal (CWZ 2021) extensions: (a) one-sided / signed-t variants (§7); (b) covariates in the conformal proxy (X_jt, eqs 4/6 — current proxy is outcomes-only); (c) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies. The joint test, pointwise CIs, and average-effect CI have landed. |
conformal.py, synthetic_control_results.py |
CWZ-2021 | Heavy | Low |
ContinuousDiD CGBS-2024 extensions. (a) covariates= kwarg — DONE (reg/dr); (b) discrete-treatment saturated regression (treatment_type="discrete") — DONE; (c) lowest-dose-as-control per Remark 3.1 when P(D=0)=0 (control_group="lowest_dose") — DONE (discrete + continuous mass-point, single-cohort; estimand ATT(d)−ATT(d_L); see REGISTRY Note #7). Remaining (all deferred NotImplementedError, documented): estimation_method="ipw" on the dose curve (scalar-adjustment / degenerate); covariates= × survey_design= (weighted OR + weighted nuisance IF); multi-cohort heterogeneous-support discrete aggregation (support-aware: average each dose only over the cohorts that observe it); multi-cohort lowest_dose (within-cohort d_L reference + support-aware cross-cohort aggregation); and covariates= × lowest_dose (conditional-PT-relative-to-d_L estimand). Single-cohort / 2-period / shared-support multi-cohort are supported. |
continuous_did.py |
CGBS-2024 | Heavy | Low |
Rust-backend solve_ols(return_vcov=True) is run-to-run NONDETERMINISTIC (~1e-14 rel): 8 identical clustered-vcov calls in one process produced 3 distinct values (observed 2026-07-07, Apple Silicon M4, maturin --features accelerate; the pure-Python backend is bit-stable run-to-run). Same-seed reproducibility is a documented library expectation, and per the Python-canonical convention the Rust port should at minimum be deterministic. Likely rayon/threaded-BLAS reduction ordering in the faer SVD solve or the vcov meat; investigate and either force a deterministic reduction path or document the wobble as a platform contract with an explicit note. Repro: solve_ols(X(400x3), y, cluster_ids=10x40, return_vcov=True) in a loop, compare vcov[1,1] bit patterns. |
rust/src/linalg.rs::solve_ols, diff_diff/linalg.py |
wild-bootstrap follow-up | Mid | Medium |
TWFE's HC2/HC2-BM inline full-dummy build (twfe.py:280-315) duplicates the dummy-construction logic in DifferenceInDifferences(fixed_effects=...) (estimators.py:478-486). Extract a shared helper, or delegate TWFE's HC2/HC2-BM path to DiD's fixed_effects= branch (with TWFE-specific cluster-default threading), to reduce drift risk on FE naming / survey behavior / result-surface conventions. Substantive refactor — touches both estimators. |
twfe.py::fit, estimators.py::DifferenceInDifferences.fit |
follow-up | Heavy | Low |
HonestDiD Δ^SD optimal-FLCI center parity — LANDED (SE-audit B2b): replaced the flat Nelder-Mead affine-estimator optimizer with a faithful port of R HonestDiD::findOptimalFLCI's nested convex program (inner min-worst-case-bias at fixed estimator SD h via SLSQP QCQP; outer grid-zoom over h). Matches R's center + half-length + optimalVec to ~1e-3 (median ~1e-5) across a stress grid; the prior ~9% intermediate-M center drift is removed (widths always matched, coverage unaffected). Analytical folded-normal cv is more accurate than R's MC .qfoldednormal. Golden honest_flci_golden.json + TestHonestFLCIParityR. |
honest_did.py::_flci_solve |
SE-audit | Done | Low |
Consolidates the former #### Performance tech-debt table and the standalone
## Performance Optimizations section. (Speculative / low-value perf notes — numba JIT,
generic sparse-FE, QR+SVD rank-detection redundancy, check_finite bypass — moved to
Deferred → Parked.)
| Issue | Location | Origin | Effort | Priority |
|---|---|---|---|---|
EfficientDiD conditional path: the largest remaining O(n) stage is the sieve/nuisance construction outside the tiled pass (~9s at 10k). (The _ridge_solve_weights Python-prep shave landed 2026-07-07 — the omega_stack[rest] fancy-index copy and tail scatter are skipped when no row is zero-masked, byte-identical outputs; the zero_mask abs scan itself remains, needed for correctness.) |
efficient_did_covariates.py |
CS-scaling | Mid | Low |
Rust solve_ols always runs a full thin SVD (equilibrated, gelsd-parity); at 40 covariates it is the top per-cell solver item on a CS dr fit (1.04s of 3.1s at 2M rows, 95 calls). A Cholesky/QR fast path with SVD fallback — mirroring the Phase 3 Python-side pattern (certify well-conditioned, fall back verbatim) — is the natural lever, but solve_ols is the universal OLS entry point (every estimator), so the blast radius needs the full backend-parity treatment (TestSolveOLSSkipRankCheckParity posture: fitted-values parity, not beta). |
rust/src/linalg.rs::solve_ols |
CS-scaling | Heavy | Low |
ImputationDiD dense (A0'A0).toarray() scales O((U+T+K)^2) — OOM risk on large panels (only triggers when the sparse solver fails). Needs an alternative dense fallback or richer sparse strategy. |
imputation.py |
#141 | Heavy | Medium |
CR2 Bell-McCaffrey DOF uses a naive O(n²k) per-coefficient loop over cluster pairs; Pustejovsky-Tipton (2018) Appendix B has a scores-based formulation avoiding the full n×n M. Switch when a user hits a large-n cluster-robust design. |
linalg.py::_compute_cr2_bm |
Phase 1a | Heavy | Low |
Rust-backend CR2 Bell-McCaffrey: falls through to NumPy (the leverage/Satterthwaite-DOF path needs return_dof support, which the Rust vcov dispatch excludes). The one-way HC2 kernel landed 2026-07-07 (compute_robust_vcov_hc2, mirrors the NumPy hc2 branch at ~1e-15; near-singular hat-diagonal sentinel + Python-side warn-and-HC1-fallback). |
rust/src/linalg.rs |
Phase 1a | Mid | Low |
Adopt snap_absorbed_regressors (FE-spanned regressor two-stage snap + LSMR confirmation) on the ImputationDiD lead-indicator path — lead columns are the most plausible FE-spanned regressors, and the truncated-MAP-iterate exposure documented at utils.py applies; today only solve_ols rank detection guards it. Behavior change beyond the demean-modernization refactor, so deferred from that PR. |
imputation.py::_compute_lead_coefficients |
demean-modernization | Mid | Low |
| Issue | Location | Origin | Effort | Priority |
|---|---|---|---|---|
SE-audit CI-lock — remaining "fiddly bits" after the second coverage batch landed (that batch pinned C2 dof_hc2_bm/dof_per_coef via CI-inversion, C3 LOO df, C4 estimatr HC1/CR1 intercept SE, C5 Yatchew p/sigma2_lin/sigma2_diff, and the G2 fixest cluster-SE band). Still deferred, each needing a golden regeneration, new computation, or a documented-deviation call — (a) G2 hetero/cluster is DONE (2026-07-07): the unbalanced/heteroskedastic-DGP regen landed, the DiD-path hetero AND cluster CR1 SEs are machine-precision-locked (the plain-OLS CR1 matches fixest exactly — the DOF-convention deviation is absorbed-FE-only), and the TWFE cluster band-pin is retained for the documented non-nested-FE ssc deviation (tracked under "Needs external reference"); TWFE has no public unclustered-hetero surface (auto-cluster convention). Remaining: (b) PlaceboTests boundary_gap — a permutation randomization-inference margin NOT computed anywhere in code (a new feature + result field, not a coverage lock); (c) StackedDiD intercept SEs (se_cr1/cr2_intercept, C1) — MEASURED to diverge ~0.3% from R: a nuisance-parameter reference-cell/parameterization gap, NOT machine-precision lockable (the event-study interaction SEs already match ~2e-13; surfacing it would add an unasserted, R-divergent public field); (d) estimatr classical intercept SE — same documented O(1/n) projection/DOF deviation as the slope (reference-only, excluded from parity). C6 CLOSED 2026-07-08 (dr remainder measured ~6e-11 ATT / ~2e-11 rel SE on the no-cov fixture and tightened to 1e-8 with the previously-missing SE assertion added; reg/ipw ATT bands 0.02/0.05 also tightened to 1e-8; the covariate-DR 2e-3 band stays as documented small-sample numerics). C7/C8 definitions were lost with the audit session scratchpad — re-derive from a fresh loose-tolerance inventory before acting. |
benchmarks/R/generate_fixest_did_twfe_golden.R, tests/test_fixest_did_twfe_parity.py, tests/test_methodology_stacked_did.py, tests/test_methodology_placebo.py |
SE-audit | Mid | Low |
Not currently actionable. Retained for provenance + AI-review deviation-documentation.
| Issue | Location | PR | Priority |
|---|---|---|---|
SyntheticControl fit-snapshot residency (_SyntheticControlFitSnapshot) — investigated 2026-07-07, parked: the snapshot ALIASES the fit's own working pivots (zero extra construction cost); the retained residency implements the documented freeze contract (post-fit mutation of estimator inputs must not change in_space_placebo() / leave_one_out() / conformal output on an already-returned results object, and __getstate__ already excludes it from pickles). A compact array representation saves only pandas overhead (the float panel dominates); releasing residency needs new API surface (release/opt-out flag) or a freeze-contract change. Revisit on user demand for very large donor panels. |
synthetic_control.py, synthetic_control_results.py |
follow-up | Low |
Stratified survey-PSU multiplier-weight draw-tiling — investigated 2026-07-07, parked: the stratified generator (generate_survey_multiplier_weights_batch) consumes ONE sequential rng stream stratum-major (rng.choice(size=(n_bootstrap, n_h)) per stratum, then lonely-PSU pooling), so draw-chunked assembly CANNOT reproduce the stream bit-identically (contra the old row's parenthetical) — it would need per-stratum generator state skipping (PCG64.advance + per-weight-type variate accounting; fragile) or a stream-layout change (MC-level SE changes → baseline/golden recapture + REGISTRY note). Stratified designs have few PSUs, so the full (n_bootstrap × n_psu) matrix rarely matters; unstratified (the large-n_units case) is already tiled. Revisit only if a large-PSU stratified design hits memory, as a documented stream change. |
diff_diff/bootstrap_chunking.py::iter_survey_multiplier_weight_blocks |
follow-up | Low |
CBWSDID covariate balancing (StackedDiD(balance="entropy")) v1 supports only balanced event windows + weighting="aggregate"; unbalanced/ragged panels fail closed (unit-count vs observation-count corrector convention unresolved off balanced panels). Matching-based balancing and the repeated 0→1/1→0 episode extension are also deferred. Documented in REGISTRY StackedDiD "Covariate balancing (CBWSDID)" Notes. |
stacked_did.py, balancing.py, REGISTRY |
follow-up | Low |
dCDH: Phase-1 per-period placebo DID_M^pl has NaN SE (no IF derivation for the per-period aggregation path). Multi-horizon placebos (L_max ≥ 1) have valid SE. |
chaisemartin_dhaultfoeuille.py |
#294 | Low |
| dCDH: survey cell-period allocator's post-period attribution is a library convention, not derived from the observation-level survey linearization. MC coverage is empirically close to nominal; a formal derivation (or covariance-aware two-cell alternative) is deferred. Documented in REGISTRY survey IF expansion Note. | chaisemartin_dhaultfoeuille.py, REGISTRY |
#408 | Medium |
dCDH by_path: survey-aware backward-horizon (placebo + predict_het + survey_design) raises NotImplementedError (and _compute_heterogeneity_test warn-and-skips to forward-horizon-only heterogeneity) — the Binder TSL cell-period allocator's REGISTRY justification is tied to post-period attribution; backward horizons would put ψ_g mass on a pre-period cell. Needs the pre-period cell allocator derived. |
chaisemartin_dhaultfoeuille.py, REGISTRY |
follow-up | Medium |
HonestDiD Δ^RM ARP confidence sets (consolidates the former "Honest DiD Improvements" checklist): uses a naive FLCI instead of the paper's ARP conditional/hybrid sets (Sections 3.2.1-3.2.2). ARP infrastructure exists but the moment-inequality transformation needs calibration; CIs are conservative (valid coverage). Sub-items folded here: improved C-LF via direct optimization instead of grid search (honest_did.py:947); hybrid inference methods; event-study-specific bounds per post-period; simulation-based power analysis for honest bounds. (CallawaySantAnnaResults support has landed.) |
honest_did.py |
#248 | Medium |
Conley vcov_type for IF / GMM estimators (consolidates 8 near-identical rows). No reference implementation exists for any of these spatial-HAC × influence-function/GMM compositions; each was rejected at __init__ with a deferral pointer here. SunAbraham + WooldridgeDiD-OLS conley have shipped (within-transform via solve_ols). Per estimator: • CallawaySantAnna — Conley kernel × per-(g,t) IF aggregation (staggered.py). • TripleDifference — × the 3-pairwise-DiD IF decomposition w3·IF_3 + w2·IF_2 - w1·IF_1 (triple_diff.py). • ImputationDiD — × Theorem-3 per-unit IF sigma_sq = (cluster_psi_sums**2).sum() (imputation.py). • EfficientDiD — × per-unit EIF _compute_se_from_eif (efficient_did.py). • TwoStageDiD — thread into the GMM sandwich meat _compute_gmm_variance; the SpilloverDiD _compute_gmm_corrected_meat machinery could be adapted to score S_g = gamma_hat' c_g - X'_{2g} eps_{2g} but two-stage-GMM × Conley has no reference (two_stage.py). • StackedDiD — methodology-blocked, not plumbing: the stacked design replicates each control unit across sub-experiments, so Conley's distance matrix sees same-unit copies at distance 0 (K(0)=1); needs a per-stack spatial identifier (stacked_did.py). • SyntheticDiD — uses variance_method ∈ {bootstrap, jackknife, placebo}, no analytical sandwich for Conley to plug into; needs an analytical-sandwich path or a spatial-block bootstrap (Politis-Romano 1994) (synthetic_did.py). • Conley + survey weights / survey_design — score-reweighting is mechanical but the PSU×spatial-kernel interaction and replicate-weight spatial variance are non-trivial (Bertanha-Imbens 2014 covers cluster-sample, not Conley); raises NotImplementedError at the linalg validator (linalg.py::_validate_vcov_args). |
(per sub-item) | follow-up · Phase 1b · Phase 5 | Low-Med |
HeterogeneousAdoptionDiD Phase 4.5 C still-open: (a) replicate-weight designs (BRR/Fay/JK1/JKn/SDR) — per-replicate weight-ratio rescaling for the OLS-on-residuals refit isn't covered by the multiplier-bootstrap composition; each linearity-family helper raises NotImplementedError on replicate weights. (b) lonely_psu='adjust' + singleton-strata on the Stute family — the pseudo-stratum centering transform isn't derived for the Stute CvM functional. |
had_pretests.py |
Phase 4.5 C | Low |
HeterogeneousAdoptionDiD mass-point vcov_type in {hc2, hc2_bm} raises NotImplementedError — OLS leverage x_i'(X'X)^{-1}x_i is wrong for 2SLS; needs the x_i'(Z'X)^{-1}(...)(X'Z)^{-1}x_i correction plus an R/Stata (ivreg2 small robust) parity anchor. |
had.py::_fit_mass_point_2sls |
Phase 2a | Medium |
HeterogeneousAdoptionDiD trends_lin × survey_design: per-group linear-trend slope under survey weighting is not derived from the paper. Raises NotImplementedError across all 3 trends_lin surfaces. |
had.py, had_pretests.py |
#389 | Low |
SpilloverDiD(survey_design=...) replicate-weight variance (BRR/Fay/JK1/JKn/SDR): Wave E.1 ships Taylor-linearization only. Per Gerber (2026) Appendix A the IF-reweighting shortcut does NOT apply to TwoStageDiD-class estimators (gamma_hat is weight-sensitive); correct support needs per-replicate full re-fit of both stages. |
spillover.py, survey.py::compute_replicate_refit_variance |
follow-up | Low |
SpilloverDiD(vcov_type="conley", conley_lag_cutoff>0, survey_design=...) no-effective-PSU serial Bartlett HAC: weights-only / strata-only designs without a cluster fallback raise NotImplementedError (each pseudo-PSU appears in one period, so the serial cross-period loop contributes zero). Needs a unit-level serial fallback derivation or routing through conley_unit with documented IF-allocator asymmetry. |
spillover.py, two_stage.py::_compute_stratified_serial_bartlett_meat |
Wave E.2 tail | Low |
SpilloverDiD data-driven d_bar selection (Butts 2021b / 2023 JUE Insight cross-validation). |
spillover.py |
follow-up | Low |
LPDiD non-absorbing exit-event dynamics (Dube et al. 2025 online Appendix C eta_h^{g,n}): the shipped non_absorbing modes estimate the entry-effect estimands (Eq. 12/13) only; separate dynamic event-studies for treatment switch-offs are not implemented. Needs the exit-event clean-sample derivation + estimand contract. |
lpdid.py, REGISTRY |
PR-C follow-up | Low |
LPDiD non-absorbing interior-gap support: non-absorbing modes require a gap-free panel within each unit's observed span and raise on interior time gaps (the [t-L, t+h] window conditions can't be verified across a gap). The absorbing path already reindexes interior gaps to the calendar grid; extending that fail-closed handling (per-window gap masking) to non-absorbing is deferred. |
lpdid.py::_prepare_panel |
PR-C follow-up | Low |
Blocked on tooling absent from CI (no workflow installs R/Stata/Julia). A clear path exists but parity can't be verified without a local toolchain.
| Issue | Location | PR | Priority |
|---|---|---|---|
StaggeredTripleDifference R cross-validation: CSV fixtures not committed (gitignored); tests skip without local R + triplediff. Commit fixtures or generate deterministically. |
tests/test_methodology_staggered_triple_diff.py |
#245 | Medium |
StaggeredTripleDifference R parity: benchmark only tests the no-covariate path (xformla=~1). Add covariate-adjusted scenarios + aggregation-SE parity assertions. |
benchmarks/R/benchmark_staggered_triplediff.R |
#245 | Medium |
StaggeredTripleDifference per-cohort group-effect SEs include WIF (conservative vs R's wif=NULL); documented in REGISTRY. Could override the mixin for an exact R match (verification needs R triplediff). |
staggered_triple_diff.py |
#245 | Low |
| WooldridgeDiD follow-up cluster (PR-B Stage D/E fail-closed surfaces; re-enable after R/Stata validation): • QMLE sandwich uses aweight cluster adjustment (G/(G-1))·(n-1)/(n-k) vs Stata's G/(G-1) (conservative); add a qmle weight type if Stata goldens confirm a material difference (wooldridge.py, linalg.py). • response-scale APE / log-link coefficient bridge for R `etwfe(family=poisson |
logit)cell-level parity — needsemfx() APE extraction or link-inversion with baseline-mean adjustment (generate_wooldridge_golden.R, test_methodology_wooldridge.py). <br>• aggregate(weights="cohort_share")on survey-weighted fits:_n_g_per_cohortuses rawunit.nunique(); implement design-weighted unit totals per cohort (paper W2025 §7) and lift the ValueError gate (wooldridge.py, wooldridge_results.py). <br>• unconditional inference for cohort_share accounting for ω̂_g sampling uncertainty (W2025 §7.5); currently NaN-closed (wooldridge_results.py). <br>• cohort_trends=True × survey_designand× control_group="never_treated"raiseNotImplementedError (unvalidated TSL variance / trend columns spanned by placebo cell-dummies) (wooldridge.py). <br>• Stata jwdidgolden-valueTestReferenceValues (tests/test_wooldridge.py`). |
wooldridge.py, wooldridge_results.py, linalg.py, benchmarks |
#216 · PR-B |
Extend WooldridgeDiD method ∈ {logit, poisson} with vcov_type ∈ {classical, hc2, hc2_bm}: composing HC2 leverage + Bell-McCaffrey DOF with the QMLE pseudo-residual sandwich needs derivation + R parity vs clubSandwich::vcovCR(glm, type="CR2"). Rejected at __init__. |
wooldridge.py |
follow-up | Medium |
PreTrendsPower CS/SA anticipation=1 R-parity fixture: R pretrends has no anticipation parameter, so the Python _extract_pre_period_params anticipation filter isn't R-parity-locked. Build a synthetic CS/SA result with anticipation=1 and assert γ_p matches R's slope_for_power(). (Mechanism already covered by MC + full-VCV tests.) |
tests/test_methodology_pretrends.py, generate_pretrends_golden.R |
PR-C | Low |
Harmonize SunAbraham's HC1 within-transform finite-sample correction with fixest::sunab() — SA applies n/(n-k_dm), fixest applies n/(n-k_total) (counts absorbed FE); ~1-2% SE difference, documented as a "Deviation from R" and pinned at atol=5e-3. Either thread df_adjustment or keep as an intentional, R-verified difference. |
sun_abraham.py, linalg.py |
follow-up | Low |
Absorbed-FE clustered CR1 with non-nested FE: for absorb=[FE1,FE2], cluster=FE1 (e.g. absorb=["unit","time"], cluster="unit"), fixest counts the non-nested FE (time) in the CR1 (n-1)/(n-k) finite-sample denominator, but the clustered path uses only k_visible. D4 harmonized the non-clustered classical/hc1 full-K scale (_absorbed_fe_vcov_scale) and left the clustered path unchanged — correct for FE nested in the cluster, a small deviation for non-nested FE (documented in REGISTRY within-transform note). Thread a non-nested df_adjustment into the clustered CR1 factor; verify vs fixest::feols(..., cluster=). |
linalg.py, estimators.py |
SE-audit D4 | Low |
Rust multiplier-bootstrap weight RNG (generate_bootstrap_weights_batch) seeds Xoshiro256PlusPlus::seed_from_u64(seed+i) per row; audit Python callers (sdid.py, efficient_did_bootstrap.py, bootstrap_utils.py) for parity-test gaps and, where a numpy-canonical equivalent exists, pre-generate in Python and pass through PyO3 (same fix shape as TROP RNG parity #354). |
rust/src/bootstrap.rs, bootstrap_utils.py |
follow-up | Medium |
SyntheticDiD bootstrap cross-language parity anchor vs R synthdid::vcov(method="bootstrap") or Julia Synthdid.jl (refit-native). Same-library validation is in place; Julia is the cleanest target. Tolerance ~1e-6 (BLAS+RNG paths preclude 1e-10). |
benchmarks/R/, benchmarks/julia/, tests/ |
follow-up | Low |
CS R helpers hard-code xformla = ~1; no covariate-adjusted R benchmark for the IRLS path. |
tests/test_methodology_callaway.py |
#202 | Low |
CallawaySantAnna bootstrap: align p-value computation with R did's symmetric-percentile method (former "CallawaySantAnna Bootstrap Improvements" section). |
staggered.py |
— | Low |
bias_corrected_local_linear (lprobust) Phase-1c follow-ups: extend golden parity to kernel ∈ {triangular, uniform} (epa-only today); expose vce ∈ {hc0,hc1,hc2,hc3} on the public wrapper once R goldens exist (port supports all four; needs a per-mode generator + a hc2/hc3 q-fit-leverage decision); clustered-DGP auto-bandwidth parity is blocked upstream on an nprobust singleton-cluster bug in lpbwselect.mse.dpi (Phase-1c DGP 4 uses manual h=b=0.3). |
_nprobust_port.py, local_linear.py, generate_nprobust_lprobust_golden.R |
Phase 1c | Low-Med |
HeterogeneousAdoptionDiD Stute-family Stata-bridge parity: no public R Stutetest package exists; would add benchmarks/stata/generate_stute_golden.do + a Stata dependency. |
benchmarks/stata/, tests/test_stute_test_parity.py |
follow-up | Low |
LPDiD regression-adjustment SE — no runnable R reference. The RA influence-function cluster SE is canonically Stata teffects ra ... atet vce(cluster) only; no R package computes it (alexCardazzi/lpdid does direct covariate inclusion, not RA). Today the RA point is R-anchored (~1e-12), the SE is pinned + MC-coverage-validated (coverage_lpdid_ra.py). Follow-up: contribute the RA path to alexCardazzi/lpdid so a runnable R RA reference exists — only a trusted anchor once cross-checked vs Stata teffects (else circular). |
tests/test_methodology_lpdid.py, benchmarks/python/coverage_lpdid_ra.py |
#B2 follow-up | Low |
LPDiD survey scope gaps (PR-D1 deferrals). Survey support covers the variance-weighted default path only. (a) survey_design + reweight=True (the equally-weighted / regression-adjustment IF path) is rejected: the weighted RA influence-function variance has no runnable survey reference (same class as the RA-SE row above - survey::svyglm anchors only the OLS/WLS path). (b) Replicate-weight survey designs (BRR/Fay/JK1/JKn/SDR) and (c) non-pweight (fweight/aweight) types are rejected pending demand. |
lpdid.py, REGISTRY #8 |
PR-D1 | Low |
LPDiD non-absorbing R-parity - DONE (PR-C2) via an independent fixest::feols Eq. 12/13 reconstruction (point+SE ~1e-13/~1e-15 vw; effect_stabilization reweighted point + pinned SE). alexCardazzi/lpdid's nonabsorbing_lag proved NOT a faithful Eq. 13 (off-switch clamp + non-paper boundary/placebo window; diverges ~0.01-0.05 even on a monotone panel), so it is recorded as a divergent reference, not a gate. Residual external-reference gap: the authors' canonical non-absorbing SE/RA is Stata lpdid/teffects only (no faithful R analogue) - same class as the absorbing RA-SE row above; revisit if a Stata toolchain or a corrected R package appears. |
benchmarks/R/generate_lpdid_golden.R, tests/test_methodology_lpdid.py |
PR-C2 | Low |
HeterogeneousAdoptionDiD Phase-3 R-parity: ships coverage-rate validation on synthetic DGPs, not tight point parity vs chaisemartin::stute_test / yatchew_test (needs bootstrap-seed-semantics + B alignment across numpy/R). |
tests/test_had_pretests.py |
Phase 3 | Low |
Doable in principle, but no current caller and/or explicitly out of paper scope.
| Issue | Location | PR | Priority |
|---|---|---|---|
CallawaySantAnna unbalanced-panel R parity — LANDED via allow_unbalanced_panel=True (matches R did::att_gt(allow_unbalanced_panel=TRUE) / DRDID::reg_did_rc: ATT bit-exact on cells AND dynamic aggregation via fixed unit-cohort-mass pg + a per-unit WIF; SE up to the documented CR1 sqrt(G/(G-1)) factor). The earlier "weighting" framing was a mis-diagnosis — on unbalanced panels the dominant divergence from R is the estimator (within-cell differencing vs RC-on-pooled-obs), not only the weighting; both are resolved by the flag. The DEFAULT path keeps within-cell differencing as a documented design choice and now emits a UserWarning on unbalanced input (no-silent-failures). Remaining deferred: survey_design= × allow_unbalanced_panel= (per-obs vs per-unit weight resolution — currently fail-closed NotImplementedError); and covariate / ipw / dr × the flag R-parity verification (the RC path supports them; the committed golden covers reg no-cov). |
staggered.py, staggered_aggregation.py |
SE-audit D3 | Low |
CallawaySantAnna event-study bucket/weight construction is duplicated between the analytical aggregator (staggered_aggregation.py::_aggregate_event_study) and the multiplier bootstrap (staggered_bootstrap.py): both group (g,t) by e = t - g, apply the finite/NaN/reference masks, and read cohort weights. Both already consume the same source-materialized universal reference cells (so they agree), but the bucket logic is copy-pasted. Extract one shared helper returning per-event-time buckets (finite cells, NaN cells, reference flags, cohort weights, combined-IF inputs) used by both. Pure refactor; gate on byte-identical analytical + bootstrap output. |
staggered_aggregation.py, staggered_bootstrap.py |
SE-audit D3 | Low |
StackedDiD survey re-resolution intra-file dedup (raw-weight extraction ×3, compose-normalize ×3, resolve-on-stacked ×2). The cross-estimator ContinuousDiD/EfficientDiD panel-to-unit collapse consolidation LANDED (#226 shared helpers ResolvedSurveyDesign.subset_to_units_by_row_idx / build_unit_first_row_index); StackedDiD is deliberately NOT on that path (control units are duplicated across sub-experiments, so it re-resolves at stacked granularity rather than collapsing to one row per unit). The residual is stacked-specific, low value, and touches the numerically-sensitive composed-weight renormalization. Post-filter re-resolution / metadata-recompute unification across the three estimators was assessed and is not warranted — they use genuinely different mechanisms and already delegate to shared _resolve_survey_for_fit / compute_survey_metadata. |
stacked_did.py |
#226 | Low |
solve_ols residual memory floor is inherent to SVD-based lstsq after the PR-E marshalling slim (rust-side allocator high-water 15.32 -> 7.81 GB on the 2.4M x 130 clustered solve; remaining = one fused equilibrated input copy + thin-SVD U transient + vcov scores block + LAPACK gelsd internals on the python path). Further reduction requires the tall-skinny-QR algorithm change - same trade-off as the parked QR-reuse row below (library-wide last-digit perturbation + loses the second independent collinearity detector); the two land together if ever reopened. Diagnosis + measurements in docs/performance-plan.md. |
rust/src/linalg.rs::solve_ols, diff_diff/linalg.py |
PR-E | Low |
SunAbraham-family fits are SOLVER-bound after the v3.6.x demeaning speedups: on the county-class shape (3.1k units x 60 months, ~100 interaction columns) solve_ols = ~60% of fit time (faer SVD 1.24s + pivoted-QR rank detection 0.70s of 3.27s), and the share grows further under the Rust demean kernel; the pivoted QR alone is ~20% of the whole fit (measured attribution in docs/performance-plan.md). Candidate fix is QR-reuse (one factorization for rank detection + solve). Parked per the 2026-07 correctness-first decision: the change perturbs every coefficient in the library at the last digits (golden/parity/bit-identity recapture) and removes the SVD's independent singular-value truncation - a second, different near-collinearity detector that is deliberate defense-in-depth for the no-silent-failures contract (the FE-span guard episode showed borderline cases are real). Re-open only on practitioner demand for wide event studies; any implementation gates on fixest/R end-to-end parity, not just internal consistency. |
linalg.py::solve_ols, linalg.py::_detect_rank_deficiency |
PR-C attribution | Medium |
| dCDH parity-test SE/CI assertions only cover pure-direction scenarios; mixed-direction SE comparison is structurally apples-to-oranges (cell-count vs obs-count weighting). | test_chaisemartin_dhaultfoeuille_parity.py |
#294 | Low |
HeterogeneousAdoptionDiD joint cross-horizon covariance / sup-t bands: per-horizon SEs use independent sandwiches (paper-faithful pointwise CIs per Pierce-Schott Fig 2). Follow-ups (low demand): IF-based stacking for joint cross-horizon inference; analytical H×H covariance on the weighted ES path. (A sup-t band on the unweighted ES path shipped via the clustered band — cluster= fires the simultaneous band on the unweighted path.) |
had.py::_fit_event_study |
Phase 2b / 4.5 B | Low |
HeterogeneousAdoptionDiD event-study staggered timing beyond the last cohort: Phase 2b auto-filters to the last cohort (paper App B.2); earlier-cohort effects aren't HAD-identified (redirect to dCDH). Full staggered HAD needs a different identification path (out of paper scope). |
had.py::_validate_had_panel_event_study |
Phase 2b | Low |
HeterogeneousAdoptionDiD survey-aware support-endpoint test (research, waits on literature): needs a calibrated support-infimum test under complex sampling (endpoint EVT × survey-aware functional CLT × tail-empirical-process theory). Permanent NotImplementedError on qug_test(survey_design=...); rationale in REGISTRY § "QUG Null Test" Note (Phase 4.5 C0). |
had_pretests.py::qug_test |
Phase 4.5 C0 | Low |
HeterogeneousAdoptionDiD Phase-4.5 weight-aware auto-bandwidth MSE-DPI selector (~300 LoC); users pass h/b explicitly today. Plus replicate-weight SurveyDesigns on the continuous-dose paths (Rao-Wu-style per-replicate weight-ratio rescaling for the local-linear intercept IF). |
_nprobust_port.py::lpbwselect_mse_dpi, had.py::_aggregate_unit_resolved_survey |
Phase 4.5 | Low |
HeterogeneousAdoptionDiD Phase-4 Pierce-Schott (2016) replication harness — waived (2026-05-20): R parity at atol=1e-8 on the same 3 DGPs is a strictly stronger anchor than reproducing Fig 2's pointwise CIs on the LBD-restricted PNTR panel (paper §5.2 self-acknowledges NP estimators too noisy there). Re-open only on user demand. See REGISTRY HAD Deviations Notes #3/#4. |
benchmarks/, tests/ |
Phase 2a | Low |
HeterogeneousAdoptionDiD time-varying dose on event study: Phase 2b rejects panels where D_{g,t} varies within a unit for t≥F (constant-dose convention, App B.2). A time-varying-dose estimator is a future PR; current behavior is front-door rejection. |
had.py::_validate_had_panel_event_study |
Phase 2b | Low |
HeterogeneousAdoptionDiD repeated-cross-section support: paper §2 allows panel OR RCS, but Phase 2a is panel-only (RCS inputs rejected by the balanced-panel validator). Needs an RCS identification path (pre/post cell means) with its own validator + data_mode surface. |
had.py::_validate_had_panel |
Phase 2a | Medium |
HeterogeneousAdoptionDiD Phase-3 nprobust bandwidth for Stute variants on continuous regressors (currently OLS residuals from a 2-parameter linear fit, no bandwidth selection). Not in paper scope. |
had_pretests.py::stute_test |
Phase 3 | Low |
SpilloverDiD(ring_method="count"): count-of-treated-in-ring (paper §3.2) is methodologically supported by Butts but re-introduces functional-form dependence; expose behind an explicit kwarg gate + warning. |
spillover.py |
follow-up | Low |
TwoStageDiD paper-permitted estimand variants (Gardner 2022): the Eq.(5) P̄-period-average estimand and the fn.8 full-sample first-stage variant have no public parameter. Documented gardner-2022-review.md; surface as estimand= / first_stage= if a use case arises. |
two_stage.py |
follow-up | Low |
bias_corrected_local_linear multi-eval grid (neval > 1) with cross-covariance (covgrid=TRUE). Not needed for HAD; useful for multi-dose diagnostics. |
_nprobust_port.py::lprobust |
Phase 1c | Low |
Rust local-method estimate_model → unify to solve_wls_svd (the global-method's SVD helper) for sub-1e-14 bootstrap-SE parity. The local-method bootstrap parity test passes at atol=1e-5; the residual ~1e-7 is roundoff, not a user-visible correctness bug. |
rust/src/trop.rs, rust/src/linalg.rs |
follow-up | Low |
Validate the .txt AI guides (llms-full.txt, llms-practitioner.txt) as executable snippets — not low-lift (re-scoped 2026-06-01): only ~20% of ~112 fenced blocks are standalone-runnable; the rest are signature pseudo-code, context fragments, or data-shape-specific. Needs signature-block detection + a context/data skip-allowlist + per-snippet fixtures. |
tests/test_doc_snippets.py |
#239 | Low |
TestWorkflowDoesNotExecutePRHeadCode (CodeQL #14 guard) doesn't model bash/sh/./source <script> execution, multi-line python3 -c bodies, shell-var indirection, eval, find -exec, xargs -I. Catches common accidental regressions (16 forms); closing the residuals needs multi-line shell parsing + script-exec allowlists — diminishing return given the documented threat model. |
tests/test_openai_review.py, .github/workflows/ai_pr_review.yml |
#436 | Low |
Calendar-time aggregation (R did feature gap) — blocks 1 ported test in test-att_gt.R. |
— | — | Low |
Speculative / low-value performance notes (relocated from the old ## Performance Optimizations): numba JIT for bootstrap loops — blocked by the numpy/pandas/scipy-only dependency policy; generic sparse-matrix handling for large FE; QR+SVD rank-detection redundancy in solve_ols (QR overhead is minimal vs the SVD solve — correctness over micro-opt; skip_rank_check already exists for known-full-rank hot paths); incomplete check_finite=False bypass (scipy's QR in _detect_rank_deficiency() still validates; edge-case only). All Low, none correctness-affecting. |
linalg.py::solve_ols |
— | Low |
| Decision | Location | Verified |
|---|---|---|
bread_inv reuse not bit-identically achievable. "Factor (X'WX) once, reuse across HC2/HC2-BM" can't be done bit-identically (the bar for a pure perf refactor of the inference path). Internal bread ops solve against different RHS (X.T, eye, meat+temp.T, contrasts); only same-RHS results are bit-reusable. Measured: lu_solve(lu_factor(A),B) differs from solve(A,B) up to 6.4e-15; the inv(A)@meat@inv(A) sandwich differs up to 1.24e-14 — both nonzero and below the affected goldens' tolerances (1e-12/1e-10), so a broad reuse would silently shift SEs without tripping the suite. The one genuine bit-identical redundancy (a duplicated solve(bread, X.T) in the unweighted one-way hc2_bm+return_dof path) is dwarfed by that path's dense M=I−H build, so the saving is negligible. |
linalg.py::compute_robust_vcov |
2026-06-01 |
R-script-per-test consolidation has no CI impact. No CI workflow installs R, so every R-parity test skips in CI behind a per-file availability gate — consolidating Rscript spawns yields zero CI speedup. test_methodology_twfe.py already session-caches its R fits. The only residual is a LOCAL-dev micro-opt for test_methodology_continuous_did.py / test_methodology_callaway.py (re-spawn library(...) per call). Low value; retained as a local-dev note. |
tests/test_methodology_continuous_did.py, tests/test_methodology_callaway.py |
#139 / 2026-06-07 |
HeterogeneousAdoptionDiD mass-point IV bread is non-symmetric — _rank_guarded_inv inapplicable. The structural rank-guard sweep (continuous_did / two_stage / spillover / conley) excluded had.py's ZtWX = Zd'WX ([1, instrument]' × [1, endogenous]): it is a non-symmetric 2×2 Wald-IV bread (V = ZtWX_inv @ Omega @ ZtWX_inv.T), and _rank_guarded_inv assumes a symmetric PSD Gram (symmetric D=diag(A) equilibration + eigendecomposition), so applying it would be methodologically wrong. The existing fallback already returns NaN SE on a singular bread; an IV-appropriate near-singular guard would need a different mechanism. |
had.py |
structural-rank-guard / 2026-06-28 |
ImputationDiD SE vcov is already rank-guarded upstream. Excluded from the structural rank-guard sweep: the lead/effect vcov comes from solve_ols(..., return_vcov=True, rank_deficient_action=...) at the OLS fit (imputation.py:~2316), which already drops rank-deficient columns. The only raw inverse (solve(V_gamma, gamma), imputation.py:~2530) is the pretrends Wald F-test statistic with a safe NaN fallback — a test statistic, not a sandwich bread — so there is no garbage-SE exposure. No structural rank-guard needed. |
imputation.py |
structural-rank-guard / 2026-06-28 |
Survey TSL SE intentionally counts genuine-subpopulation zero-weight PSUs (matches R, NOT a bug). Re-examined the former "count only positive-weight PSUs in the correction" item (origin PR-B). _compute_stratified_psu_meat's finite-sample correction (1-f_h)·n_{PSU,h}/(n_{PSU,h}-1) and PSU-mean centering keep zero-weight PSUs — this is the full-design domain-estimation convention (Lumley 2004 §3.4; R survey::svyrecvar(subset())), already documented in REGISTRY § "Subpopulation Analysis" (the survey-vcov path deliberately differs from the positive-weight invariance applied outside it). The ATT is exactly invariant; the SE is intentionally NOT invariant to genuine-subpopulation zeroing (it should differ from a naive physical subset — that is the whole point of subpopulation()). Repro (scratchpad): zeroing a full PSU vs physically dropping it differs ~5e-3 rel — the Lumley-correct gap, and R's svyrecvar(subset()) produces the matching SE (only df differs; see the § "Subpopulation Analysis" Deviation note). The only truly invariance-violating shape — appending synthetic new all-zero PSUs — does not arise in any estimator path (real padding reuses existing PSU labels and is already bit-invariant, or is genuine domain estimation via prep.py's zero-padded full-design cell variance). "Fixing" the meat to positive-weight-only would break the documented Lumley/R parity. Regression-locked by tests/test_survey.py::TestZeroWeightPsuConventionWaiver. |
survey.py (_compute_stratified_psu_meat) |
PR-B / 2026-06-30 |
Not backlog — current-state notes, monitoring, and scheduled removals.
| Issue | Location | Priority | Notes |
|---|---|---|---|
| MultiPeriodDiD wild bootstrap not supported (falls back to analytical) | estimators.py:1647 |
Low | Edge case |
predict() raises NotImplementedError |
estimators.py:890-911 |
Low | Rarely needed |
For survey-specific limitations (NotImplementedError paths), see the Current Limitations section of survey-roadmap.md.
Target: ideally < 1000 lines per module; modules ≥3000 lines are candidates for splitting, 2000-3000 are monitored, 1000-2000 are accepted as a cohesion / scope trade-off. Updated 2026-05-15.
| File | Lines | Action |
|---|---|---|
chaisemartin_dhaultfoeuille.py |
8636 | Consider splitting (per-path / placebos / survey IF / aggregation) |
had_pretests.py |
4951 | Consider splitting (Stute / Yatchew / QUG / joint pretests) |
had.py |
4593 | Consider splitting (continuous / mass-point / event-study / survey paths) |
staggered.py |
3963 | Consider splitting — grew through survey + aggregation features |
linalg.py |
3601 | Consider splitting (vcov surfaces) only if cohesion preserved — unified backend; vcov / solver paths tightly coupled |
diagnostic_report.py |
3380 | Consider splitting (per-method renderers + provenance) |
power.py |
3196 | Consider splitting (power analysis + MDE + sample size) |
synthetic_did.py |
2819 | Monitor — variance methods + survey paths |
honest_did.py |
2785 | Monitor |
business_report.py |
2653 | Monitor — per-method narrative renderers |
imputation.py |
2475 | Monitor |
survey.py |
2466 | Monitor — grew with Phase 6 features |
utils.py |
2396 | Monitor |
prep_dgp.py |
2057 | Monitor |
triple_diff.py |
2053 | Monitor |
estimators.py |
1991 | Acceptable |
two_stage.py |
1985 | Acceptable |
chaisemartin_dhaultfoeuille_results.py |
1981 | Acceptable |
prep.py |
1876 | Acceptable |
efficient_did.py |
1793 | Acceptable |
sun_abraham.py |
1713 | Acceptable |
continuous_did.py |
1682 | Acceptable |
results.py |
1676 | Acceptable |
staggered_triple_diff.py |
1619 | Acceptable |
_nprobust_port.py |
1412 | Acceptable |
practitioner.py |
1402 | Acceptable |
trop_global.py |
1350 | Acceptable |
trop_local.py |
1339 | Acceptable |
local_linear.py |
1332 | Acceptable |
wooldridge.py |
1305 | Acceptable |
chaisemartin_dhaultfoeuille_bootstrap.py |
1175 | Acceptable |
bacon.py |
1144 | Acceptable |
pretrends.py |
1133 | Acceptable |
stacked_did.py |
1050 | Acceptable |
conley.py |
1006 | Acceptable |
visualization/ |
4316 | Subpackage (split across 7 files) — OK |
vcov_type has subsumed the previously-proposed se_type knob. DifferenceInDifferences
and TwoWayFixedEffects accept vcov_type ∈ {classical, hc1, hc2, hc2_bm, conley}
(the validated set in linalg.py::_VALID_VCOV_TYPES); cluster-robust variance comes from
cluster= alongside the heteroscedasticity kind (hc1+cluster ⇒ CR1 Liang-Zeger;
hc2_bm+cluster ⇒ CR2 Bell-McCaffrey, including the weighted WLS-CR2 port; the N>1
absorbed-FE + weights composition is supported via iterative alternating-projection demeaning, #586);
wild cluster bootstrap is the separate inference="wild_bootstrap" path. Threading
vcov_type through the 8 standalone estimators is complete (Phase 1b); four
(CallawaySantAnna, TripleDifference, ImputationDiD, EfficientDiD) are permanently
narrow to {hc1} per their influence-function variance, and TwoStageDiD is likewise
narrow (Gardner GMM meat has no single cross-stage hat matrix). The per-estimator
vcov_type="conley" extensions: SunAbraham + WooldridgeDiD-OLS are shipped; the
IF/GMM estimators are tracked in
Deferred → Paper-gated.
Mypy reports 0 errors. All mixin attr-defined errors resolved via TYPE_CHECKING-guarded
method stubs in the bootstrap mixin classes.
Visualization tests skip when matplotlib / plotly are not installed (see
pytest.importorskip markers in tests/test_visualization*.py).
lambda_regandzetainSyntheticDiD(synthetic_did.py) — deprecated in favor ofzeta_omega/zeta_lambda; remove in v4.0.0 (public kwarg removal requires a major bump).
Spurious RuntimeWarnings ("divide by zero", "overflow", "invalid value") are emitted by
np.matmul/@ on Apple Silicon M4 + macOS Sequoia with numpy < 2.3, for matrices with
≥260 rows. They do not affect correctness (coefficients/fitted values are valid, designs
full rank). Root cause: Apple's BLAS SME kernels corrupt the FP status register
(numpy#28687,
#29820; fixed in numpy ≥ 2.3 via
PR #29223). Not reproducible on M3, Intel, or
Linux.
linalg.py:162— warnings in fitted-value computation (X @ coefficients); seen intest_prep.pyduring treatment-effect recovery (n > 260).triple_diff.py:307,323— warnings in propensity-score computation (IPW/DR with covariates); logistic-regression overflow in edge cases (separate from the BLAS bug).- Long-term: revert to the
@operator when numpy ≥ 2.3 becomes the minimum supported version.