Skip to content

Commit 79a5f08

Browse files
authored
docs(lwdid): pre-release audit - guide coverage, scope-limitation docs, Rust parity tests (#787)
1 parent 4bd5d6b commit 79a5f08

15 files changed

Lines changed: 866 additions & 19 deletions

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535
remain outside the Stata anchor and are documented as such (REGISTRY `## LPDiD`
3636
Deviation 4; `DEFERRED.md` non-absorbing row narrowed to the RA +
3737
placebo/pooled-pre residuals).
38+
- **LWDiD guide coverage + scope-limitation docs + Rust parity tests** (pre-release
39+
audit). The estimator was missing from the full, practitioner, and autonomous
40+
wheel-shipped AI guides (only the `llms.txt` catalog line existed): `llms-full.txt`
41+
gains an `LWDiD` section (introspection-pinned constructor/fit signatures, estimand-
42+
by-timing-mode caveats, a Choosing-an-Estimator row) and an `LWDiDResults` entry;
43+
`llms-practitioner.txt`'s few-treated branch and `llms-autonomous.txt`'s support
44+
matrix / §4.3 / §4.8 now route to LWDiD's exact small-N inference niche. Scope
45+
limitations are now documented on every relevant surface (panel-only, no sampling
46+
weights / `survey_design=`, no `anticipation=`, HonestDiD non-admission): `lwdid.rst`
47+
Scope-limitations note, a REGISTRY Edge-cases Note, the survey-support and SE-method
48+
matrices in `choosing_estimator.rst`, `survey-roadmap.md`, and
49+
`practitioner_decision_tree.rst`, with the anticipation offset, HonestDiD admission,
50+
and survey weights recorded as DEFERRED rows (and practitioner-code-surface routing
51+
as a TODO row). New `TestLWDiDBackendParity` (5 Rust-vs-Python estimator-level
52+
equivalence tests: reg/hc1 plain + CR1, post-fit RI/WCB replay, staggered
53+
event-study, dr) and `TestLWDiDGuideSection` guide pins. `doc-deps.yaml` maps
54+
`lwdid.py` to all new surfaces; stale pre-merge wording ("forthcoming", "in
55+
review") retired from `references.rst` and the REGISTRY section.
3856
- **LWDiD tutorial notebook** (`docs/tutorials/31_lwdid.ipynb`): the replacement for
3957
the tutorial withdrawn from PR #588, authored via the numbers-locked workflow (every
4058
cited number prototyped in scripts first, notebook assembled and executed once).

DEFERRED.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ provenance and AI-review deviation-documentation: a row here (or in
2222
|-------|----------|----|----------|
2323
| LWDiD PSM matching variance: implement the Abadie-Imbens (2006) matching variance (matched-control reuse + first-stage matching uncertainty) so `estimation_method='psm'` can report valid inference instead of the current fail-closed NaN tuple (point retained; naive var(diffs)/n was invalid under with-replacement reuse) | `diff_diff/lwdid.py` | #588 | Low |
2424
| LWDiD cohort-relative sensitivity exclusions: `robustness_pre_periods` / `sensitivity_no_anticipation` currently reject multi-cohort staggered inputs because their exclusion windows are defined relative to the EARLIEST adoption (later cohorts' own pre periods fall inside the global post window and survive every restriction, mislabeling the specification). Supporting staggered inputs needs per-cohort window semantics (exclude the last k periods of each cohort's own `t < g` window before its transformation), which the current row-subset design cannot express — a per-cohort masking derivation + its aggregation contract | `diff_diff/lwdid_sensitivity.py` | #588 | Low |
25+
| LWDiD estimation-time anticipation offset (CS-parity gap): no `anticipation=` parameter exists — no-anticipation is hard-assumed and the only surface is the single-cohort `sensitivity_no_anticipation` refit diagnostic. The LW device for anticipation is the anchor-window shift — drop the last k pre-treatment periods from the transformation/anchor window (`Ybar_{i,S0}` with `S0 < S-1`, per the REGISTRY LWDiD Anticipation edge case; NOT a cohort recode g → g − k) — and exposing an `anticipation=` parameter needs that derivation checked against LW 2025 NAC/CNAS before implementation | `diff_diff/lwdid.py` | LWDiD release audit | Low |
26+
| HonestDiD rejects LWDiD event studies: `LWDiDResults.aggregate('event_study')` emits the unified EventStudyResults surface but the `honest_did.py` allowlist admits CS/Stacked sources only. Admission needs a methodology decision, not an allowlist edit: LWDiD pre-period cells are transformation-based placebos on the Appendix D control pools, and Rambachan-Roth requires those coefficients plus a joint pre/post vcov to stand as valid pre-trend evidence | `diff_diff/honest_did.py`, `diff_diff/lwdid_results.py` | LWDiD release audit | Low |
2527
| HonestDiD non-chronological declared partitions (native `MultiPeriodDiDResults` route): the Rambachan-Roth restriction matrices are built POSITIONALLY over the concatenated declared pre/post lists assuming one chronological boundary, but the native route accepts non-suffix `post_periods` / non-last-pre references and returns bounds whose restriction system does not match the Registry equations (pre-existing; surfaced by the Phase 3(a) calendar-route review, which fails closed instead). Fix = transform the declared partition into boundary form where a valid mapping exists, else reject on the native route too - needs the restriction-geometry derivation. REGISTRY HonestDiD Note records the limitation. | `diff_diff/honest_did.py` | 3(a) | Medium |
2628
| `PlaceboTests` `boundary_gap` — a permutation randomization-inference margin (SE-audit item (b)); NOT computed anywhere in code today, so this is a new feature + result field, not a coverage lock. **User-locked 2026-07-09: defer until a derivation/paper source exists** — do not design or implement from scratch. | `tests/test_methodology_placebo.py`, `diff_diff/diagnostics.py` | SE-audit | Low |
2729
| TwoStageDiD honest/pretrends container admission DEFERRED (decision revised from "widen" during the 2(b) PR-3b plan review): analytical fits carry the joint Gardner-GMM event-study covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS — the reference horizon is dropped from the no-intercept Stage-2 design and the zero anchor row appended mechanically — not contrasts against a reference period, while HonestDiD's Δ^RM/Δ^SD arithmetic hard-codes the `delta_0 = 0` normalization into its boundary/bridge constraints. Admission needs either a Stage-2 re-estimation with the reference horizon in the design or a derived residual-to-reference normalization mapping (+ its variance transform). Both consumers' TypeErrors state the deferral; see the REGISTRY TwoStageDiD Note (d). | `diff_diff/honest_did.py`, `diff_diff/pretrends.py`, `diff_diff/two_stage_aggregation.py` | 2(b) PR-3b | Low |
@@ -78,6 +80,7 @@ For survey-specific limitations (`NotImplementedError` paths), see the
7880
|-------|----------|----|----------|
7981
| RDDensityTest public bandwidth-selector helper (an `rdbwdensity`-equivalent exposed function) and a Tutorial-28 executed density-test demo cell (Act 4d; needs notebook re-execution + drift-suite updates): the selector ships internal-only and the tutorial carries a prose pointer for now. | `diff_diff/rddensity.py`, `docs/tutorials/28_rdd_scholarship_illusion.ipynb` | rddensity PR-B | Low |
8082
| LW 2025 Sec 4.3 all-eventually-treated mode for LWDiD (drop `D_infinity`, effects relative to the last cohort as reference, last cohort's own effect not estimable). Current behavior (by decision, see the REGISTRY LWDiD Sec 4.3 note): such designs raise ValueError under both control strategies rather than silently truncating | `diff_diff/lwdid_staggered.py` | #588 | Low |
83+
| LWDiD sampling weights / `survey_design=`: `fit()` accepts no weight argument on any path (passing `survey_design=` raises `TypeError`) — the LW papers derive the rolling transformation and the collapsed exact/HC/CR1 inference for unweighted panels, and no weighted counterpart of the transformation or exact-inference layer has been derived. Documented scope exclusion (REGISTRY LWDiD Edge-cases Note, `docs/api/lwdid.rst` Scope limitations note, survey-support matrix row, survey-roadmap Current Limitations row) | `diff_diff/lwdid.py` | LWDiD release audit | Low |
8184
| EfficientDiD/ImputationDiD/TwoStageDiD kit retention has no memory opt-out: EfficientDiD's kit always retains the per-(g,t) EIF dict (M-023), ImputationDiD's references the `_fit_data` panel objects (M-021, pre-existing retention), and TwoStageDiD's retains a column-subset working-frame copy (M-022, the first new panel retention); a `store_kit=False` constructor knob would restore an opt-out at the cost of post-fit aggregation on that result | `diff_diff/efficient_did.py`, `diff_diff/imputation.py`, `diff_diff/two_stage.py` | 2(b) PR-3a | Low |
8285
| MMM interop, StackedDiD totals (remainder of the estimator-owned-totals delivery - `aggregate('total')` shipped for CS/EfficientDiD/ImputationDiD/TwoStageDiD on panel non-survey fits): StackedDiD is STAGED OUT because the total estimand is ill-defined under `weighting="population"`/`"sample_share"` (a weighted estimand for which raw treated exposure is not the right mass), and its results class is view-only (a total would be its first computed level). Needs an estimand decision per weighting variant before any implementation. | `diff_diff/mmm.py`, `diff_diff/stacked.py` | mmm-interop | Low |
8386
| `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 |

TODO.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
8686
| 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 |
8787
| 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 |
8888
| 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 |
89+
| 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 |
8990
| 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 |
9091
| `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 |
9192
| `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 |

diff_diff/guides/llms-autonomous.txt

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ supported / out of scope; `warn` supported but with documented caveats;
348348
| `TwoStageDiD` | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ |
349349
| `StackedDiD` | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ |
350350
| `WooldridgeDiD` (ETWFE) | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ |
351+
| `LWDiD` | ✓ | ✓ | ✗ | ✗ | ✓ | warn | partial | ✗ | ✓ |
351352
| `EfficientDiD` | ✓ | ✓ | ✗ | ✗ | partial | ✓ | ✗ | ✗ | ✓ |
352353
| `SyntheticDiD` | ✓ | ✗ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ | partial |
353354
| `TROP` | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | partial |
@@ -358,6 +359,18 @@ supported / out of scope; `warn` supported but with documented caveats;
358359
| `RegressionDiscontinuity` | ✗ (cross-sectional; sharp: treatment = running >= cutoff; fuzzy: observed take-up via `takeup=`) | ✗ | ✗ | ✗ | ✗ | ✓ (precision only - estimand unchanged, unlike the DiD conditional-PT role; `covariates=`) | ✗ | ✗ | ✗ (follow-up) |
359360

360361
**Footnotes.**
362+
- `LWDiD` + never-treated: requires at least one never-treated unit under
363+
`control_group='not_yet_treated'` and at least two under
364+
`'never_treated'`; all-eventually-treated designs raise under both
365+
control strategies.
366+
- `LWDiD` + covariate adjustment (`warn`): unit-constant covariates only;
367+
time-varying covariate columns raise `ValueError` (the rolling
368+
transformation collapses the panel to one row per unit).
369+
- `LWDiD` + few-treated (`partial`): supports few or even a single treated
370+
unit, but the mechanism is exact small-sample inference on the collapsed
371+
regression (classical exact t / randomization inference, valid at one
372+
treated unit) - NOT synthetic weighting; the column's synthetic-control
373+
reading applies to `SyntheticDiD`/`TROP`.
361374
- `TwoWayFixedEffects` + staggered: fits but mixes positive and negative
362375
cohort-weights that violate the ATT interpretation; consult
363376
`BaconDecomposition` to quantify. Prefer any staggered-robust
@@ -494,6 +507,12 @@ estimators:
494507
under either `PT-All` or `PT-Post`; use `EfficientDiD.hausman_pretest`
495508
to pick. Requires a balanced panel (`PanelProfile.is_balanced ==
496509
True`); `fit()` raises `ValueError` on unbalanced input.
510+
- `LWDiD` (Lee & Wooldridge 2025, 2026) - unit-specific rolling
511+
demeaning/detrending converts the panel into per-(cohort, period)
512+
cross-sectional problems. Control pool selectable
513+
(`control_group='not_yet_treated'` default, or `'never_treated'`);
514+
covariates must be unit-constant; the only staggered option with an
515+
exact small-sample inference layer (see §4.8).
497516

498517
Diagnostic: `BaconDecomposition().fit(df, ...)` shows the weight allocation of a
499518
TWFE fit to 2×2 comparison types. Forbidden-comparison weight > 10% is a
@@ -656,11 +675,32 @@ but derivable from `cohort_sizes` + `has_never_treated`):
656675
unit untreated at period `t` as the donor pool (via the absorbing-state
657676
D matrix); supports staggered adoption and more complex factor
658677
structures. No covariate-adjustment surface on `fit()`.
678+
- `LWDiD` - exact small-sample inference on the collapsed cross-sectional
679+
regression: `vcov_type='classical'` (with `n_bootstrap=0`; a positive
680+
`n_bootstrap` replaces the common-timing headline inference with the
681+
unit/cluster bootstrap) gives an exact t reference
682+
distribution valid down to a single treated unit. Causal only under the
683+
LWDiD identifying assumptions (no anticipation + parallel trends - the
684+
heterogeneous-linear-trends variant for `rolling='detrend'` - +
685+
overlap) and exact only under the classical error assumptions; the
686+
canonical contract (which surfaces get the exact t, covariate designs)
687+
is the LWDiD section of `get_llm_guide("full")` / the REGISTRY "LWDiD"
688+
section. Common-timing `estimation_method='reg'` fits offer post-fit
689+
`randomization_test()` (any such fit) and `wild_cluster_bootstrap()`
690+
(additionally requires a `cluster=` fit; unclustered fits raise).
691+
Randomization inference relaxes the classical error assumptions, but
692+
it tests Fisher's SHARP null and is valid only when the observed
693+
treatment assignment is exchangeable under a credible assignment
694+
mechanism (the implementation permutes treatment labels) - in
695+
observational settings without that justification, the permutation
696+
p-value is not valid inference.
659697

660698
Classical DiD estimators will still produce estimates, but inference is
661699
unreliable with very small treated groups; cluster-robust SE relies on
662700
the number of clusters, not the number of treated units. Bootstrap
663-
methods in the library are preferred.
701+
methods in the library are one mitigation; `LWDiD`'s exact t /
702+
randomization inference is the direct small-N answer when its classical
703+
assumptions are acceptable.
664704

665705
### §4.9 Heterogeneous adoption intensity
666706

0 commit comments

Comments
 (0)