The actionable backlog: work with a clear implementation path and no external blocker.
Pull from here, top row first — rows are priority-ordered (higher priority first)
within each section. Effort (Quick ≤1 day · Mid 3-10 CI rounds · Heavy
derivation-free but large) is noted per row; Priority is carried from the originating
PR review; the Origin column points to the originating PR number or review tag.
Related tracking surfaces:
- ROADMAP.md — the public feature roadmap.
- DEFERRED.md — the deferral & decision registry: blocked / parked work and decisions on the record. Do not pull from there without first clearing the named blocker.
- docs/dev-status.md — monitoring and current-state notes (module sizes, SE / typing posture, platform quirks).
| Issue | Location | Origin | Effort | Priority |
|---|---|---|---|---|
Consolidate the remaining estimator-entangled DR/logit score variants (staggered.py::_doubly_robust + RC twins, triple_diff.py, lwdid.py, wooldridge.py) onto the shared _dr_scores.py module, each migration with its own committed oracle capture (the ContinuousDiD lift's two-tier pattern in tests/test_dr_scores.py); and add a ridge vcov path to solve_ridge if an estimator ever needs analytical ridge inference |
diff_diff/_dr_scores.py |
dml-b0 | Mid | Low |
hc2/hc2_bm floor 1 - h_ii at 1e-10 in the shared leverage meat, fabricating finite (if inflated) variances for leverage-one observations - hc3 now fails closed there (LWDiD fix wave) but the pre-existing hc2 family behavior is released surface; decide fail-closed vs keep-floor for hc2/hc2_bm |
diff_diff/linalg.py |
#588 | Quick | Low |
Numeric between-period cohorts (e.g. first_treat=4.5 with integer times) are rejected by LWDiD while CallawaySantAnna estimates them and LWDiD's own datetime/Period cohorts map to the next observed period — close the dtype asymmetry by adopting the next-observed-period mapping for numeric cohorts too (contract documented in REGISTRY cohort-encodings Note + docs/api/lwdid.rst Input Contract). Lands only after PR #588 merges |
diff_diff/lwdid.py |
#588 | Quick | Low |
Implement the LW 2026 eq. 7.9/7.10 unit-average cohort estimand (regress per-unit post-average transformed outcomes on [1, D_g] vs never-treated) as an alternative to the documented cell-mass cohort_effects convention (REGISTRY within-cohort aggregation Note; the two differ on unbalanced panels, where cell-mass weights units by observed post periods). Needs the 7.10 regression + its covariance on the NT path. Lands only after PR #588 merges |
diff_diff/lwdid_staggered.py |
#588 | Quick | Low |
Expose cell-mass overall ATT (Stata Post_avg convention; = CS-simple on balanced panels) as an aggregate extra on LWDiD results — the fit's .att is the paper's tau_omega (cohort-mean-then-treated-weight, eq. 7.18); the authors' large-N display uses cell-mass weighting instead, and both are legitimate estimands (see the REGISTRY LWDiD Aggregation note). Lands only after PR #588 merges |
diff_diff/lwdid_results.py |
#588 | Quick | Low |
Post-fit aggregate() for the staggered DDD container: StaggeredTripleDiffResults carries no AggregationMixin, which is why the phase-3(b) merge had to carry fit-time aggregate=/balance_e= onto the surviving TripleDifference (rows M-140/M-141) as the ONE documented exception to the section-6 aggregate-postfit program. Porting the container onto the M-122 aggregation contract retires both rows; note the bootstrapped-fit recompute levels will need replay or a fail-closed relay — solved for CS and EfficientDiD via the BootstrapReplaySpec state replay (the container port can adopt the same mechanism); ImputationDiD/TwoStageDiD/ContinuousDiD still track theirs. Until it lands, the DDD docs deliberately keep teaching the fit-time kwarg (the canonical route there) |
diff_diff/staggered_triple_diff_results.py, diff_diff/aggregation.py, docs/api/triple_diff.rst, docs/tutorials/08_triple_diff.ipynb |
3(b) | Heavy | Medium |
Staggered-DDD power support: simulate_power/simulate_mde/simulate_sample_size now REJECT a staggered-configured TripleDifference (both registered DDD generators emit 2x2x2 data and fit with (group, partition, post), so a staggered config would be simulated under the wrong design). Support needs a staggered DDD DGP profile plus fit-kwargs builder, and a decision on whether the mode is selected by profile or by the estimator's own config |
diff_diff/power.py |
3(b) | Mid | Low |
Bootstrap-seed provenance on multiplier-bootstrap results containers: neither StaggeredTripleDiffResults nor CallawaySantAnnaResults carries the seed that generated its bootstrap SEs / p-values / sup-t bands, so a serialized result cannot report the random configuration behind its inference. NOT a 3(b) regression - seed reaches the engine and get_params() correctly (same seed reproduces the SE bit-exactly, a different seed moves it), the gap is results-object observability only, it predates the merge, and both containers inherit it from the shared CallawaySantAnnaBootstrapMixin. Add seed (and consider n_bootstrap/bootstrap_weights/cband) to BOTH containers plus to_dict(), with seeded and unseeded pins; sequence it with the M-014 container unification rather than schema-changing one container mid-merge. Precedent for exposing it: ContinuousDiDResults, EfficientDiDResults, SyntheticDiDResults already do |
diff_diff/staggered_triple_diff_results.py, diff_diff/staggered_results.py |
3(b) | Quick | Low |
ContinuousDiD.pscore_trim still validates 0.0 <= x < 0.5, i.e. it admits 0, while TripleDifference tightened to 0 < x < 0.5 in phase 3(b) (row M-142) on the grounds that trim=0 disables the np.clip(pscore, trim, 1-trim) overlap guard keeping the 1/(1-p) weights finite. The same argument applies to ContinuousDiD; aligning it was out of scope for a DDD merge and is recorded in the REGISTRY staggered-mode Note rather than left as silent drift. TripleDifference additionally gained a TYPE guard in 3(b) (reject bool/non-real-scalar/non-finite BEFORE the range comparison) because a bare 0 < x < 0.5 raises an incidental TypeError on None/str/complex/list, an ambiguous-truth error on a multi-element array, and silently ACCEPTS a 1-element array as the parameter; ContinuousDiD's np.isfinite(self.pscore_trim) and ... has the same hole. Aligning both is one change - promote the guard to a shared utils.validate_pscore_trim(value, *, allow_zero) alongside validate_n_bootstrap rather than copying it |
diff_diff/continuous_did.py, diff_diff/utils.py |
3(b) | Quick | Low |
Staggered-mode cluster-robust ANALYTICAL SEs: cluster= raises in TripleDifference's staggered mode (and is accepted-then-ignored on the deprecated class), so clustered inference there is bootstrap-only. Implementing a clustered analytical path for the GMM-combined influence function would let the raise become a real lane |
diff_diff/_staggered_triple_diff_engine.py |
3(b) | Heavy | Low |
diagnostic_report admission for EventStudyResults surfaces (the TWFE event-study mode + aggregate('event_study') containers): DiagnosticReport/BusinessReport now REJECT the surface explicitly (Phase 3(a); previously a silent zero-check report / all-null headline) and practitioner_next_steps serves the generic fall-through - admission needs source-aware routing (the type-name-keyed _APPLICABILITY/_HANDLERS registries cannot discriminate the unified container's producers) and a scalar-vs-per-period headline design; MPD-native results received {parallel_trends, pretrends_power, sensitivity, bacon, design_effect} |
diff_diff/diagnostic_report.py, diff_diff/business_report.py, diff_diff/practitioner.py |
3(a) | Mid | Medium |
DiagnosticReport public skip bookkeeping omits RUNNER-level skips: applicable_checks reflects only gate outcomes, so a check whose runner returns status="skipped" (heterogeneity's empty/non-finite branches, _pt_event_study's empty-coefs branch - a pre-existing pattern, now also reachable via a failed post-fit event-study derivation on bootstrapped / kit-less ImputationDiD/TwoStageDiD/ContinuousDiD fits) stays listed as applicable while skipped_checks/schema["skipped"] omit it, so automation reading the public fields can misclassify the check as completed; reconcile runner-returned skipped sections into the public bookkeeping (all checks, one convention) or resolve those availabilities at the gate |
diff_diff/diagnostic_report.py |
derived-ES review | Mid | Low |
Align the NATIVE pretrends pre_periods= contract with the container routes' fail-closed validation: _extract_pre_period_params's MPD branch silently filters an explicit pre_periods= selection (unknown labels, the reference, unusable-inference rows dropped without error, caller order preserved) while both container routes validate every requested label and enforce calendar chronology (the relative route since M-024; the calendar route since 3(a) R8) - the relative route also silently collapses DUPLICATE requested labels (the calendar route rejects them), and both filter on SE only while the calendar route additionally requires a finite EFFECT (3(a) R9); one contract across all three routes, with pinned rejection messages |
diff_diff/pretrends.py |
3(a) R8 | Quick | Low |
EventStudyResults inference-provenance fields: the container records no vcov_type/cluster_name/n_clusters/df_convention/Conley metadata, so a serialized surface cannot distinguish unit auto-clustering from explicit clustering, survey, Conley, or the one-way carve-out (3(a) R9 review). Adding them is a cross-producer M-092 schema amendment (six builders, to_dict/summary rendering, surface-suite pins) - follow the pre-cut amendment convention (optional fields appended last, ledger note same-diff) rather than bolting onto one producer |
diff_diff/results_base.py |
3(a) R9 | Mid | Low |
| Opt-in singleton-group pruning for TwoWayFixedEffects (static + event-study mode; reghdfe parity): singleton units/periods are currently RETAINED class-wide - the within-demeaned row is zero so points are unchanged, but N/G/residual-df count it and CR1/finite-sample SEs shift (~0.41019 -> 0.40962 measured; REGISTRY "Deviation from R" Note, R5 review) - reghdfe iteratively drops singletons by default while fixest retains them (diff-diff matches fixest); an opt-in knob needs iterative unit+period pruning with consistent cluster/survey/replicate/Conley array subsetting and a default-flip decision protocol (moves published SEs) | diff_diff/twfe.py, diff_diff/estimators.py, diff_diff/utils.py |
3(a) R5 | Mid | Low |
Cohort-timing validation input for the simultaneous-adoption event-study family (TWFE event_study=True + MultiPeriodDiD through 3.9): an optional first_treat=/cohort= column so simultaneous adoption becomes checkable under the contract-valid time-invariant D_i indicator - today the staggered-adoption advisory derives timing from within-unit 0->1 transitions, so it can only fire on off-contract time-varying D_it input, and with valid D_i adoption timing is not observable in the inputs at all (REGISTRY "staggered-adoption detection limit" Notes, both sections); design questions: validate-only vs steering error, and interplay with the M-011 removal |
diff_diff/twfe.py, diff_diff/estimators.py |
3(a) R2 | Mid | Medium |
Bootstrapped ES REPLAY containers (CS and EfficientDiD) publish the analytical survey_metadata.df_survey provenance scalar beside percentile inference on survey fits — a cross-estimator provenance residual (shipped CS behaves identically; the per-row df/inference channels are correctly NaN, the scalar metadata field is the residual). Evaluate clearing/gating it on both estimators together (cross-surface twins) |
diff_diff/staggered_results.py, diff_diff/efficient_did_results.py, diff_diff/results_base.py |
EDiD-replay review | Quick | Low |
ImputationDiD/TwoStageDiD aggregate() recompute levels on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence; M-021/M-022); ImputationDiD's per-target psi machinery makes seeded replay tractable (the panel-backed kit retains everything the psi precompute reads), TwoStageDiD's per-level GMM scores are function-locals and would need retention |
diff_diff/imputation_results.py, diff_diff/two_stage_results.py, diff_diff/aggregation.py |
2(b) PR-3b | Mid | Low |
ContinuousDiD aggregate('event_study') on bootstrapped fits fails closed (M-025); a seeded post-fit bootstrap-ES replay is tractable - the multiplier draws are seeded (np.random.default_rng(self.seed)) - but needs the FULL per-cell _bootstrap_info (bread/ee_treated/Psi_eval/dPsi_*/beta_pred) the pruned kit deliberately drops, so shipping it means a kit-payload change with its own memory contract |
diff_diff/continuous_did_aggregation.py, diff_diff/continuous_did_results.py |
2(b) PR-3c | Mid | Low |
EfficientDiD, ImputationDiD, ContinuousDiD and HeterogeneousAdoptionDiD are the outstanding M-092 event-study df-provenance holes: the container's per-row df is all-NaN even on survey fits where a finite _survey_df governed the p-values (the container-level scalar df_survey IS exposed - the hole is the PER-ROW column only; no event_study_df/df_inference field; pre-existing, NOT a regression of the M-023 PR - today's builder output is identical). The kits now retain the scalar (ImputationDiD's since 2(b) PR-3b, ContinuousDiD's since 2(b) PR-3c - same shape: scalar df_survey exposed, per-row column all-NaN, identical to each fit-time surface); threading it into the per-row channel is a contained follow-up |
diff_diff/efficient_did_results.py, diff_diff/imputation_results.py, diff_diff/continuous_did_results.py, diff_diff/results_base.py |
2(b) PR-3a | Quick | Low |
practitioner step_name="heterogeneity" producer-side collisions: three OTHER estimators' advice steps reuse the key with non-heterogeneity labels (:975 ContinuousDiD dose-response, :1022 Triple placebo-group, :1413 LPDiD WAS arrays), so DiagnosticReport's heterogeneity completion silently drops that unrelated advice from next_steps via _filter_steps - the same latent collision fixed for StackedDiD in M-024 (renamed to sub_experiment_balance). Renaming these changes those estimators' report output; audit + rename with per-estimator pins. |
diff_diff/practitioner.py |
2(b) PR-2 review R9 | Quick | Low |
PreTrendsPower violation='linear' on CS base_period='varying' input targets the wrong alternative: δ_pre = M · |t| assumes level coefficients against a common reference, but varying-base pre-treatment effects are consecutive-period comparisons (constant increments under a linear trend). Both CS-sourced routes now WARN (REGISTRY PreTrendsPower Note), and universal-base GAPPED grids fail closed via the reference_event_times common-reference guard; what remains is the varying-base resolution - either transforming the violation vector through each coefficient's actual base mapping (needs per-horizon base provenance) or requiring base_period='universal' for the linear benchmark - a per-estimator methodology decision with a hand-calculated linear-violation gate |
diff_diff/pretrends.py |
2(b) PR-1 R5 | Mid | Medium |
run_placebo_test/run_all_placebo_tests expose ONE overloaded time param (calendar for timing/group tests, 0/1 post dummy forwarded into the M-137/M-138 callees - works only because a two-period 0/1 calendar column is both); redesign the wrapper signatures (dual time=/post= params or a split) so each callee gets an honestly-named argument |
diff_diff/diagnostics.py |
2(c)-ii PR-B | Mid | Medium |
absorbed_fe_rank N>=3 general rank: the helper keeps sum(levels-1) for 3+ absorbed dims, exact for independent connected dims but an over-count for duplicated/nested triples (measured a(5),b(4),c==b(4): true 7 vs formula 10) and for disconnected N-way graphs. Two-way is component-exact. Deriving general N-way FE rank is a hypergraph problem; do it with a reference (fixest's fixef.rm/reghdfe df_a) rather than a guess. See docs/methodology/variance-conventions.md D3. |
diff_diff/utils.py |
#variance-inventory | Mid | Low |
SyntheticControl conformal (CWZ 2021) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies — the residual-permutation shortcut is only valid for time-permutation-invariant proxies (SC/Lasso/DiD); an AR proxy needs innovation permutation. |
diff_diff/conformal.py, diff_diff/synthetic_control_results.py |
CWZ-2021 | Heavy | Low |
Re-run the R-dependent benchmark refresh so docs/benchmarks.rst's TWFE "SE Rel Diff 0.1%" cell reflects the 3.9 K_reference convergence (expected 0.1% -> 0.0%; the table is generated, never hand-edited — the movement is noted in the CHANGELOG entry). |
docs/benchmarks.rst, benchmarks/R/ |
#variance-inventory | Quick | Low |
SunAbraham: a cohort not observed at its own reference relative period (e = -1 - anticipation) makes that cohort's block collinear, so QR drops an unnamed column (dropping 1 of 12 columns (column 9)) and overall_att comes back NaN. Found by auditing the sibling estimator while fixing the ETWFE analogue (#724); PRE-EXISTING, not introduced there. Lower severity than #724 — that returned a silently WRONG finite number, this returns NaN with a rank warning — but the event-study surface still looks complete, so a user may not notice the loss. SA already omits its reference explicitly and tracks _reference_observed, so the fix is per-cohort support for that flag rather than the ETWFE-style redesign. |
diff_diff/sun_abraham.py |
#724-audit | Mid | Low |
Define N_g (W2025 Eqs. 7.4/7.6) for UNBALANCED panels where comparison-support filtering removes every observation of some units in an estimated cohort, then replace the fail-closed guard with the defined behavior. _n_g_per_cohort is read off the final sample, so those units vanish from the cohort-share weights; measured on a cohort supplied with 100 units of which 90 appear only at a dropped period, aggregate(weights="cohort_share") moves 1.8078 -> 3.8157. The paper assumes a balanced panel and does not say whether N_g counts the supplied cohort or the surviving units, and the two disagree materially, so aggregate currently raises naming the cohorts and counts ([M-125]); weights="cell" is unaffected and balanced panels never trip it. Settle the estimand (likely: count the supplied cohort, since ATT(g,t) is a cohort-level quantity, but that weights units with no retained observation) and gate with a test computing Eq. 7.4 by hand on unequal cohort sizes. |
diff_diff/wooldridge_results.py, diff_diff/wooldridge.py |
#729-followup | Mid | Medium |
WooldridgeDiD + survey_design= does not support DOMAIN ESTIMATION, so BOTH row-deleting paths are currently REFUSED (NotImplementedError, all three methods) rather than performed: unidentified-cohort exclusion ([M-123]) and comparison-support period filtering ([M-125]). One fix unblocks both. Implementing it properly means zero-padding the excluded rows' weights while retaining strata/PSU/FPC, per REGISTRY Subpopulation Analysis (Phase 6) / Lumley (2004) 3.4, so TSL variance and df_survey = n_PSU - n_strata use the full design (naive deletion measured 22 -> 14 on a two-stratum panel). SurveyDesign.subpopulation() already implements the contract and SpilloverDiD Wave E.3 is the in-repo precedent; the blocker is that the weighted within-transform rejects zero-weight units, shared machinery behind 7 estimators. Landing it would turn both refusals back into supported fits. Gate with a SurveyDesign.subpopulation() parity test on ATT, TSL SE and survey df where the excluded cohort exhausts a PSU. |
diff_diff/wooldridge.py, diff_diff/utils.py |
#724-codex-R4/R5 | Heavy | Medium |
WooldridgeDiD REFUSES a fit whose only treatment cells fall inside the anticipation window, discarding estimates it successfully computed. _require_estimable_overall_att ([M-124]) raises when no cell has t >= g, because the overall ATT averages only t >= g (W2025 excludes anticipation leads) while cells from t >= g - anticipation are ESTIMATED. For a cohort never observed at or after its own treatment date, those anticipation-window ATT(g, t) are real, identified estimates and are thrown away with the fit. The refusal is a stopgap for the missing estimand semantics, not the intended end state. Real fix: decide what such a fit should return — most likely the per-cell ATT(g, t) plus an overall that is explicitly undefined with a stated reason (not a bare NaN, per the no-silent-NaN convention) — then relax the guard to that. Needs a REGISTRY note defining the estimand and an aggregate() story for the anticipation-only case. |
diff_diff/wooldridge.py |
#724-codex-R2 | Mid | Medium |
WooldridgeDiD comparison-support accounting is PARTIAL. Per-period support now runs before the solve and removes periods with no eligible comparison, reporting them (REGISTRY per-period comparison support). What remains is the per-(g, t) half: the completeness gate still refuses when a cell is lost to a cause the period filter cannot see -- treated cohorts sharing no comparison period with each other, and covariate collinearity -- so those users get a refusal naming the cell rather than an upfront diagnostic naming the cause. Real fix: compute per-cell eligible-control support and report exactly which cells are unidentified and why BEFORE solving. Note the cohort-count proxy remains invalid and is still pinned (TestOverallAttFailsClosed::test_two_cohorts_without_same_period_controls_fail_closed, verified unaffected by the period filter). |
diff_diff/wooldridge.py |
#724-codex-R2 | Mid | Medium |
WooldridgeDiD DROPS the observations of a cohort with no supported pre-period before g - anticipation ([M-123]) rather than identifying it. Excluding the rows is correct given g-1 normalization -- leaving them in silently loads the cohort's effect onto the time FE -- but dropping a cohort a user supplied is a lossy last resort. Route (b) is now SETTLED NEGATIVELY and is not the answer: the paper's no-never-treated last-cohort normalization shipped (W2025 Sec 5.4, per-period comparison support), and it does NOT identify these cohorts -- wooldridge-2025-review.md:477 is explicit that in the final period the last cohort's ATT is unidentified, and the implementation still excludes any cohort whose reference is None. Route (a) remains open: an explicit user-supplied reference period per cohort -- W2025 Section 6.1 says any pre-treatment period may serve and the pre-trend t-test is invariant to the choice, so a cohort with ANY supported pre-period is a candidate even when g-1 is missing. If route (a) also fails to identify the cohort, convert this row into a REGISTRY Note recording exclusion as the deliberate final answer. |
diff_diff/wooldridge.py, docs/methodology/REGISTRY.md |
#724 | Heavy | Medium |
WooldridgeDiD REFUSES a panel whose units split into disconnected support groups within a cohort, rather than estimating what IS identified. The connectivity guard (REGISTRY within-cohort support connectivity) correctly detects that a closed component's cells are collinear with the unit FE — previously QR dropped one silently and the overall ATT averaged an incomplete set (issue #724's failure mode via unit support). Refusing is the safe answer, not the complete one. The connected component containing the reference is still fully identified, so the estimable resolution is either (a) estimate the connected component and report the disconnected units as excluded, with the estimand restated (a sub-population of units, so it needs a REGISTRY definition and interacts with the survey-domain row above), or (b) per-component references, if a component with its own pre-period can carry its own normalization — needs a methodology decision, since components then are not comparable on one baseline. Gate with the split-support fixture in TestWithinCohortSupportConnectivity. |
diff_diff/wooldridge.py |
#724-codex-R7 | Heavy | Medium |
WooldridgeDiD fully resolves the SurveyDesign TWICE on every supported survey fit. The pre-exclusion validation pass (added so invalid metadata cannot hide in rows that cohort exclusion deletes) calls survey_design.resolve(sample), and each fitter then calls _resolve_survey_for_wooldridge -> _resolve_survey_for_fit on the same frame, repeating weight normalization, strata/PSU/FPC validation and design-array construction. Any fit that REACHES the second resolve has an unchanged sample (survey + unidentified-cohort exclusion raises first), so the first result is reusable: capture the _resolve_survey_for_fit 4-tuple early and thread it into the three fitters as an optional pre_resolved. Caveat that makes this non-trivial: sample = sample.reset_index(drop=True) runs BETWEEN the two calls, so the reused object must be verified index-independent (resolution extracts positional numpy arrays, but _inject_cluster_as_psu and the metadata recompute need checking), and the early call must stop suppressing warnings or the user loses the weight-normalization notice. Gate with a survey fit asserting one normalization warning and byte-identical SEs. |
diff_diff/wooldridge.py |
#724-codex-R10 | Mid | Low |
Consolidate the inference-df precedence duplicated across honest_did.py (3 copies at ~L655/L836/L1004) onto the shared resolve_inference_df() helper added in diff_diff/aggregation.py. The copies are correct today; the risk is drift if the survey/replicate precedence changes in one place only. (Adjacent but distinct from PR C's utils.resolve_tail_df: that is the FIT-TIME df_convention fallback resolver, this is a post-fit results READER.) |
diff_diff/honest_did.py |
#726 | Quick | Low |
ContinuousDiD CGBS-2024 remaining extensions (earlier phases — covariates= reg/dr, treatment_type="discrete", single-cohort control_group="lowest_dose" with estimand ATT(d)−ATT(d_L) — are already supported; 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 |
WooldridgeDiD does not apply the W2025 Sec 5.4 D_{G_max} x X covariate normalization, and three sibling covariate rank deficiencies are pre-existing. Measured with the period range pinned and only the never-treated units toggled: (1) time-invariant exovar is absorbed by the unit FE, 4 of 26 columns, IDENTICALLY with and without never-treated units; (2) xgvar's cell x covariate block, 19 of 41, identical on both panels; (3) xtvar under demean_covariates=False does exhibit the sum_g D_g x = x dependency that the default demeaning removes; (4) the newly-reachable case -- time-VARYING data passed through exovar, which its own docstring reserves for time-invariant covariates -- where the paper's dT_i rule would give a deterministic D_{G_max} x X drop instead of QR's arbitrary pick (coefficients unaffected, 1.35e-14; rank_deficient_action="error" raises). REGISTRY's narrowed Sec 5.4 note cross-references this row. Trap for whoever takes it: xtvar under the DEFAULT demean_covariates=True is FULL RANK -- the raw block carries demeaned values while D_g x X carries raw ones -- and forcing the drop there moves overall_att 1.11903 -> 1.46269. Pinned as-is by TestComparisonSupportFiltering::test_cells_derived_groups_did_not_leak_into_the_design. |
diff_diff/wooldridge.py |
#729-followup | Heavy | Medium |
WooldridgeDiD.n_control_units counts never-treated UNITS on control_group="never_treated" regardless of method, but on the nonlinear paths (logit/poisson) treated units' pre-treatment rows ARE the identifying comparison -- only the OLS path absorbs them into their own cells. So the reported count under-states the comparison pool exactly where the REGISTRY control-pool asymmetry note applies. Widen to not_yet_treated or (never_treated and method != "ols"), or document the count as never-treated-units-by-definition. Behavior is PRE-EXISTING; documented for now in the REGISTRY control-pool Note rather than changed, because widening moves a public results field and wants its own ledger row and test matrix. |
diff_diff/wooldridge.py |
#729-followup | Mid | Low |
WooldridgeDiD has no opt-out for comparison-support period filtering: a user who would rather see the refusal than a reduced sample cannot ask for it. Adding one means a constructor parameter (get_params/set_params propagation, transactional validation), a ledger row, and a test matrix across both predicate branches and all three rank_deficient_action modes -- deliberately out of scope for the change that introduced the filter. The always-on warning is the interim answer. |
diff_diff/wooldridge.py |
#729-followup | Mid | 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.md → Parked.)
| Issue | Location | Origin | Effort | Priority |
|---|---|---|---|---|
LWDiD staggered fit recomputes cohort-wide work across surfaces: each cohort's rolling transformation is built once per fit_staggered cohort loop, but unit-level post summaries, control-eligibility sets, and the composite path's ydot_by_cohort are re-derived independently by the cell, aggregate, and _composite_regression_aggregation paths — runtime grows ~cohorts × panel size. Cache the per-cohort transformation + post summaries in one pass and reuse; add a many-cohort unbalanced-panel benchmark |
diff_diff/lwdid_staggered.py, diff_diff/lwdid.py |
#588 | Mid | Low |
Reuse the demeaner's factorized codes in absorbed_fe_rank/absorbed_fe_cr1_k_increment instead of re-factorizing: at 186k rows the rank helper adds ~1.9 ms per absorbed fit (7.7% of the fastest Rust-served TWFE fit) and the K_reference increment ~3.2 ms per clustered-hc1 absorbed fit (~13%; see docs/performance-plan.md "Component-aware absorbed-FE rank"), and the helpers and demean_by_groups factorize the same group columns. Threading the codes through the call sites halves the factorize work; the connected_components call itself is ~1.1 ms. Deliberately not done in the correctness PRs. |
diff_diff/utils.py |
#variance-inventory | Quick | Low |
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 |
_rq_fit LP assembly is dense (A_eq = [X, I, -I] with dense identity blocks, rebuilt per cell fit): a scipy.sparse construction would cut memory and likely HiGHS time for large cells / bootstrap-heavy covariate CiC/QDiD fits. CAVEAT before doing it: a different matrix representation can change HiGHS's vertex selection at degenerate/tied QR optima - end-to-end covariate goldens are tie-selection-gated (fine), but the qr_cases tight coefficient matches may shift to the equal-loss branch; re-run the parity suite and re-calibrate if needed. |
diff_diff/changes_in_changes.py::_rq_fit |
covariates PR | Quick | Low |
Evaluate flipping DIFF_DIFF_SOLVE_OLS_FASTPATH default-ON after an opt-in soak (the 2026-07 certified normal-equations Cholesky fast path, both backends). A flip needs: golden/parity-suite recapture at the tol-bounded posture (fitted ~1e-8 abs / SE ~1e-6 rel — the default today is byte-pinned in several benchmark conventions), certification-rate telemetry across real workloads (any decline is silent-correct but forfeits the speedup), and the staged default-flip protocol used for df_convention (v4-class change). Lifecycle tracked in docs/v4-deprecations.yaml (M-008). |
diff_diff/linalg.py::_resolve_solve_ols_fastpath, rust/src/linalg.rs::solve_ols_chol |
CS-scaling | Mid | Low |
| Issue | Location | Origin | Effort | Priority |
|---|---|---|---|---|
Optional scheduled end-to-end execution gate for the MMM tutorials (29/30): a cron-only workflow (or extension of mmm-interop.yml) that executes both notebooks in isolated exact-pin environments, so a stale/invalid committed posterior cannot stay green indefinitely - today the hybrid posture (deliberate: notebooks execute locally with committed outputs; CI smoke-tests the exporters without sampling; drift tests pin source + committed-output needles) leaves the MCMC claims un-re-executed in CI |
.github/workflows/mmm-interop.yml, docs/tutorials/29_mmm_calibration_pymc.ipynb, docs/tutorials/30_mmm_calibration_meridian.ipynb |
mmm-interop | Mid | Low |
Committed fixest::feols event-study golden for TWFE event_study=True (within + pooled specs, unbalanced + covariate panels, matched CR1 cluster convention, per-period effects + vcov block) - the in-suite gates are shared-core cross-checks (TWFE-within == MPD-absorb, pooled == MPD bit-exact), so a defect common to the shared core would pass; the live-R harness (benchmarks/R/benchmark_multiperiod.R, feols(y ~ treated * time_f | unit)) validated the within design in docs/benchmarks.rst but is not a committed regression test - follow the fixest_did_twfe_golden.json committed-golden pattern (pytest.skip when absent) |
tests/test_fixest_did_twfe_parity.py, benchmarks/R/ |
3(a) R2 | Mid | Medium |
Type-blind n_bootstrap acceptance in already-validated estimators - HAD bool (isinstance(..., int) passes True, runs as 1 replicate), dCDH bool+float (its bare < 0 check passes both True and 2.5), TROP float (2.5 passes the >= 2 floor), SyntheticDiD float under all three variance methods + bool/negative under jackknife (its floor check is skipped there) - align these local checks with the utils.validate_n_bootstrap type guard (M-081 kept them out of the sweep: it scoped to previously-UNvalidated estimators only) |
diff_diff/had.py, diff_diff/chaisemartin_dhaultfoeuille.py, diff_diff/trop.py, diff_diff/synthetic_did.py |
2(d) PR-B | Quick | Low |
Evaluate adding the BaseEstimator param surface (get_params/set_params) to the exported classes that never had it - PowerAnalysis, LinearRegression, BusinessReport, DiagnosticReport, TWFEWeightsResult (a NEW public surface, deliberately out of the 2(c)-i pure-refactor scope; LinearRegression is the one fit-bearing class excluded from the contract suite's roster-completeness test). |
diff_diff/linalg.py, diff_diff/power.py |
mixin PR | Mid | Low |
Tighten the mypy suppressions that back the enforced-zero posture: burn down prep_dgp's per-module [index] override (needs a None-vs-array restructure that preserves the seeded RNG stream), and evaluate re-enabling the globally disabled codes (arg-type, return-value, var-annotated, assignment) one at a time — assignment alone hid several real annotation drifts found during the 2026-07 triage. |
pyproject.toml [tool.mypy], diff_diff/prep_dgp.py |
lint-CI | Mid | Low |
Tracking-file contract guard test: reject NEW active deferred-work pointers at TODO.md (deferred rows live in DEFERRED.md; allowlist for historical/past-tense prose and actionable-row pointers) and assert rows cross-linking a docs/v4-deprecations.yaml M-xxx id don't restate ledger status. Origin: tracking-split local review R2. |
tests/, TODO.md, DEFERRED.md |
tracking-split | Quick | Low |
Real-data CI canary for dataset-backed replication tests: test_methodology_lwdid.py's Prop 99 / Walmart goldens skip (visibly) when loaders fall back to synthetic; add a lane or canary asserting df.attrs["source"] == "lwdid_ssc_ancillary" in CI so network regressions cannot silently de-gate the replication tests. Follow-on from the loader-fallback repair (#723), which made provenance explicit but deliberately did not add a network-dependent CI lane. |
tests/test_methodology_lwdid.py, .github/workflows/ |
LWDiD validation suite | Quick | Low |
Route LWDiD into the practitioner code surfaces: practitioner_next_steps (diff_diff/practitioner.py), agent_workflow.py, DiagnosticReport, and BusinessReport never dispatch to or mention LWDiD — the AI guides now cover it, but the code surfaces need contract decisions (when to recommend it, what diagnostics to run) plus tests |
diff_diff/practitioner.py, diff_diff/agent_workflow.py, diff_diff/diagnostic_report.py, diff_diff/business_report.py |
LWDiD release audit | Mid | Low |
df-provenance completion follow-up (PR C descoping): a Wooldridge public per-row df channel (a per-key dict that can also carry the hc2_bm BM contrast dofs the scalar _df_analytic_fallback cannot represent — the unified surface's Wooldridge df column stays NaN until then); ImputationDiD lead-horizon event_study_df (needs the _compute_lead_coefficients/_aggregate_event_study return-path change plus bootstrap-clearing semantics for post rows); and the inference_df (DiD/MPD/SA/StackedDiD) vs df_inference (CS/dCDH) adapter-naming unification. NOTE (M-024): StackedDiD's df_survey=None on analytical fits is DELIBERATE - the container resolver's df_inference fallback not matching inference_df is load-bearing (normal-theory FLCI; see the REGISTRY StackedDiD M-024 Note and the pin in tests/test_event_study_consumers.py) - the unification must preserve that outcome or renegotiate it explicitly. |
diff_diff/wooldridge_results.py, diff_diff/imputation.py, diff_diff/results_base.py |
#variance-PR-C | Mid | Low |
worktree-rm safety via a tested argv helper: the prose rewrite (ask-before-remove confirmation gate, detached-HEAD reachability/rescue, tip-identity force-delete guard) was reverted to the main version because editing the prose repeatedly reintroduced shell-injection (last: sourcing a state file built from a git-derived branch name). Restore those guards in a worktree_rm.py that takes the name via file ingress, invokes git through argv arrays, and has a metacharacter-branch/path injection regression test — the pattern that worked for pr_prepare.py/premerge_scan.py. |
.claude/commands/worktree-rm.md, .claude/scripts/ |
skill-audit | Heavy | Medium |
premerge_scan.py should scan the staged blob (git show :path) for staged methodology files, not the working-tree copy — a stage-then-revert-working-copy edit currently reads the safe working version and misses the staged violation. Union staged-index findings with unstaged/untracked filesystem findings. |
.claude/scripts/premerge_scan.py |
skill-audit | Mid | Low |
Carousel PDF drift guard: the committed deck PDFs are build artifacts of their generate_*.py scripts (README rule: regenerate in the same commit), but nothing verifies a committed PDF matches its generator — a source edit without regeneration, or a stale raster chart, passes every claims test. Applies to ALL decks, not just the MMM one. fpdf embeds creation-date metadata, so a byte-compare needs fixed metadata or a per-page rendered-text manifest. Origin: mmm-carousel local review R1. |
carousel/, tests/ |
mmm-carousel | Mid | Low |
Re-add committed-range methodology scanning to /push-pr-update §3b (clean tree, commits ahead) using premerge_scan.py --range, with the comparison ref passed as data (resolved into a quoted variable in one Bash call, never a raw <placeholder>). It was removed to avoid ref interpolation; the helper already implements and tests --range. |
.claude/commands/push-pr-update.md |
skill-audit | Quick | Low |
Codex reviewer isolation (repo-wide): codex --sandbox read-only blocks writes but does NOT confine READS to the worktree/repo (verified: a probe read /etc/hosts), and prompt guards are not a security boundary. Affects /ai-review-local (which already documents and accepts this surface) and plan-review-eval dual arms equally. Evaluate OS-level isolation for codex invocations (container / sandbox-exec profile / dedicated low-privilege account exposing only the worktree). |
.claude/scripts/openai_review.py, tools/plan-review-eval/ |
plan-review-eval local review R6 | Heavy | Medium |
plan-review-eval dual arms: terminate the surviving peer subprocess when the first reviewer of a dual pair fails (today an early Claude failure still waits out the codex peer — up to its 3600s ceiling — before the run becomes INFRA_ERROR; needs process handles threaded through _call_claude/call_codex or a shared cancellation event). |
tools/plan-review-eval/plan_adapters/plan_reviewer.py |
plan-review-eval local review R3 | Mid | Low |
Reimplement the retired /review-plan --updated (delta re-review, ## Delta Assessment section) and --pr <url> (PR-comment feedback-coverage) modes in the plan-review skill — descoped from the initial skill ship (the advertised-but-unimplemented flags were removed). --updated needs prior-review load + a delta diff; --pr needs a gh comment fetch + a new reviewer-prompt token. |
.claude/skills/plan-review/SKILL.md |
plan-review skill local review | Mid | Low |
plan-review skill dual-mode peer cancellation: when reviewer 1 (Opus subagent) fails, terminate the concurrently-running codex codex_review.py subprocess instead of waiting out its 1200s ceiling (and vice-versa). Interactive analogue of the eval-harness peer-cancellation row above; needs the codex process handle surfaced from codex_review.py so the skill can signal it. |
.claude/skills/plan-review/SKILL.md, .claude/skills/plan-review/codex_review.py |
plan-review skill local review R2 | Mid | Low |
Clean-negative precision re-run: Campaign 1's s3_negative plans were base_sha-contaminated (real defects at the pinned base), so hallucination rate cleared (dual 3.4%) but trivia-flooding on a genuinely clean plan is unmeasured. Small future run (~30 s3-style reviews on 3-5 constructed-clean plans, not the full 120-matrix). |
tools/plan-review-eval/ |
campaign-1 verdict | Mid | Low |
trop-silent-drop criteria regression: the rewritten criteria.md dropped a silent-failure catch the OLD criteria had (arm A caught it, B/C missed — campaign-1 A-vs-B contrast). Patch the criteria + re-validate (re-opens the criteria identity, so gated behind a re-validation run). |
.claude/skills/plan-review/criteria.md, tools/plan-review-eval/ |
campaign-1 verdict | Mid | Medium |
The Stata jwdid parity arms cover BALANCED panels only. jwdid_alltreated pins the all-eventually-treated cell set, N and SE ratio on the mpdta subset (191 units, 955 rows, 764 estimated), but every arm is a balanced frame, so REGISTRY's parity claim is scoped to that case. The comparison-support predicate's UNBALANCED behavior -- where G_max may be unobserved at later periods so the closed form t < G_max - anticipation does not hold and support is set by whichever cohorts are actually observed -- is verified against the predicate directly, not against Stata. Add an unbalanced jwdid arm to close that gap. |
benchmarks/stata/generate_etwfe_cs_golden.do, tests/test_etwfe_cs_stata_parity.py |
#729-followup | Mid | Low |