Skip to content

Commit 4037712

Browse files
authored
docs(v4): 4.0-cut framing - cut-window gate, ledger/spec refresh, M-080 re-scope (#854)
1 parent cb2ce97 commit 4037712

11 files changed

Lines changed: 658 additions & 81 deletions

‎TODO.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
8080
| 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 |
8181
| `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 |
8282
| `_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 |
83-
| 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 |
83+
| 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 a staged default-flip protocol of its own - a later flip is a major-version change on its own schedule, re-evaluated in 4.x. Decision at the 4.0 cut recorded 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 |
8484

8585
### Testing / docs
8686

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
### Documentation
2+
- **4.0-cut framing** (the first PR of the 4.0 cut, at v3.13.0): `docs/v4-design.md`
3+
re-synced with the library as shipped since 3.9 - the estimator roster now counts
4+
LWDiD/DMLDiD/DurationDiD (27 -> 24 at 4.0, 23 estimators + Bacon in the diagnostic
5+
family), the diagnostic family names the TWFE weights containers and
6+
`DurationDiDPretestResults`, and section 9 carries the ten-PR cut sequence. `M-080`
7+
(auto-cluster policy) re-scoped to the measured roster: static `TwoWayFixedEffects`
8+
already auto-clusters at unit, `SpilloverDiD` is the one estimator whose default SE
9+
moves at 4.0, `cluster=False` becomes the uniform explicit off-switch, and the
10+
by-construction estimators keep no auto-cluster metadata by decision (new "Default
11+
clustering roster" section in `docs/methodology/variance-conventions.md`; REGISTRY
12+
Notes on SpilloverDiD, StackedDiD, ImputationDiD, TwoStageDiD, DMLDiD, DurationDiD).
13+
`M-008` (`DIFF_DIFF_SOLVE_OLS_FASTPATH` default) decided: evaluated, kept off. New
14+
ledger row `M-150` gives `WooldridgeDiDResults.to_dataframe(level="event")` its own
15+
locator (twin of `M-136`). The migration guide's auto-cluster section, appendix and
16+
orientation table follow the ledger (111 appendix rows).
17+
18+
### Internal
19+
- **Ledger cut window** (`docs/v4-deprecations.yaml` header `cut_window: "4.0"`,
20+
`cut_base: "3.13.0"`; spec section 11): rows scheduled at 4.0 may flip terminal
21+
ahead of the version bump so the removals land over many PRs while `__version__`
22+
stays 3.13.0; `tests/test_v4_matrix.py` relaxes only the early-removal guard for the
23+
window version, refuses any other version bump while early-terminal rows exist, and
24+
keeps due enforcement unchanged. The id snapshot and `ROW_COUNT_FLOOR` now include
25+
`M-148`/`M-149` (shipped by #838 without extending them) and `M-150` (132 rows).
26+
`M-091`'s roster records the four later-born `Diagnostic` containers; `M-050`'s
27+
`code_refs` record that `diff_diff/_deprecation.py` survives 4.0 as 5.0-window
28+
infrastructure; `M-045`/`M-115` name the legacy-CR1 remap branches their removal
29+
deletes. A `maint/3.13` branch is cut at `v3.13.0` for 3.13.x patches.

‎diff_diff/_deprecation.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
33
This module is a deliberate LEAF: it imports only the standard library, never
44
other ``diff_diff`` modules, so every estimator module can import it without
5-
cycles (the ``_base.py`` precedent). It is private and unexported; the 4.0
6-
removal PR deletes the shims that use it.
5+
cycles (the ``_base.py`` precedent). It is private and unexported. The 4.0
6+
removal PRs delete the 3.9 shims that use it, but the module itself SURVIVES
7+
4.0: the 4.0 -> 5.0 ``FutureWarning`` property shims of the results-storage
8+
flip (ledger rows M-016 and M-050..M-058) ride ``deprecated_field_property``
9+
through the 5.0 window (recorded in M-050's ``code_refs``).
710
811
The helpers implement the ledger's shim contract (``docs/v4-deprecations.yaml``,
912
groups ``renames-*``; rules in ``docs/v4-design.md`` section 8):

‎diff_diff/wooldridge_results.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -899,7 +899,7 @@ def to_dataframe(
899899
level : "simple" | "group" | "calendar" | "event_study" | "gt"
900900
Use "gt" to export raw group-time effects. ("event" is a
901901
deprecated spelling of "event_study"; warns and is rejected
902-
in 4.0 - row M-086.)
902+
in 4.0 - row M-150, the to_dataframe twin of M-086.)
903903
aggregation : str, optional
904904
Deprecated alias for ``level`` (row M-044); warns with
905905
``FutureWarning`` and will be removed in 4.0.

‎docs/methodology/REGISTRY.md‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2150,6 +2150,11 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only.
21502150
- Bootstrap does not update pre-period SEs (they are from the lead regression)
21512151
- When `balance_e` is set, lead indicators are restricted to balanced cohorts; the full Omega_0 sample (including never-treated) is kept for within-transformation
21522152
- Only affects event study aggregation; overall ATT and group aggregation unchanged
2153+
- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c2):
2154+
the default `cluster_var = unit` lives INSIDE the unit-level influence-function
2155+
variance, so no unclustered variance exists; the uniform 4.0 `cluster=False`
2156+
off-switch is an accepted explicit no-op (not an error, not a new SE). Roster in
2157+
`docs/methodology/variance-conventions.md` "Default clustering roster".
21532158
- **Note:** `pretrends=True` with analytical `survey_design` (strata/PSU/FPC) is supported. The lead regression uses survey-weighted demeaning, WLS point estimates, and `compute_survey_vcov()` for design-based VCV. The full survey design is preserved (subpopulation approach): Omega_0 scores are zero-padded back to full-panel length so PSU/strata structure is maintained for variance estimation. The F-test in `pretrend_test()` uses the full-design `df_survey` as denominator df. Replicate-weight survey designs raise `NotImplementedError` with `pretrends=True` because per-replicate lead regression refits are not yet implemented.
21542159
- **Note (pretrends lead-regression tail df, 3.9 / M-127):** the per-lead
21552160
`gamma_h` t/p/CI previously used silent normal-theory z on plain clustered
@@ -2229,6 +2234,10 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only.
22292234

22302235
**Primary source:** [Gardner, J. (2022). Two-stage differences in differences. arXiv:2207.05943.](https://arxiv.org/abs/2207.05943)
22312236

2237+
- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c2):
2238+
as for ImputationDiD, the default `cluster_var = unit` is internal to the unit-level
2239+
influence-function machinery, so the uniform 4.0 `cluster=False` off-switch is an
2240+
accepted explicit no-op. Roster in `docs/methodology/variance-conventions.md`.
22322241
- **Note (rank-guarded TSL-variance bread):** The Stage-2 variance bread `(X_2'WX_2)^{-1}` is inverted by the shared `_rank_guarded_inv` (`diff_diff/linalg.py`) on BOTH the analytical (`two_stage.py`) and multiplier-bootstrap (`two_stage_bootstrap.py`) surfaces. `np.linalg.solve` raised only on an exactly-singular bread (prior fallback: dense `lstsq`); a **near**-singular `X_2'WX_2` would otherwise flow a garbage inverse (~1e13) into the SE. The rank-guard truncates redundant directions → finite SE on the identified subspace (NaN at rank 0) and warns. A dropped (unidentified) Stage-2 coefficient (event-time / group effect) is reported with **NaN** SE on both the analytical and bootstrap surfaces — not the zero-filled `0`. `X_2` is the Stage-2 indicator design (treatment/event-time/group dummies), not user covariates. See the CallawaySantAnna "rank-guarded IF standard errors" Note. Sibling of axis-A finding #17.
22332242

22342243
**Key implementation requirements:**
@@ -2430,6 +2439,13 @@ The paper text states a stricter bound (T_min + 1) but the R code by the co-auth
24302439
- [x] Anticipation parameter support
24312440
- [x] Never-treated encoding (0 and inf)
24322441
- [x] Survey design support (Phase 3): Q-weights compose multiplicatively with survey weights; TSL vcov on composed weights; survey design columns propagated through sub-experiments. Replicate weights supported via estimator-level refit with Q-weight composition (see Replicate Weight Variance section).
2442+
- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c3
2443+
in `docs/methodology/variance-conventions.md` "Default clustering roster"): the
2444+
stacked regression clusters by construction and `cluster` accepts only `'unit'` /
2445+
`'unit_subexp'`; the uniform 4.0 `cluster=False` off-switch is therefore REJECTED
2446+
here with the existing validator message rather than yielding an unclustered SE
2447+
(no such variance is defined for the stacked design). The spec's "becomes an
2448+
instance of the general policy" applies to the default resolution only.
24332449
- **Note (intercept SEs excluded from R parity):** for the base StackedDiD `clubSandwich` parity fixture (not the CBWSDID `cbwsdid` fixture below), stacked event-study *interaction* SEs match R at ~2e-13, but the regression *intercept* SE differs from R by a measured ~0.3% — a nuisance-parameter reference-cell/parameterization gap, not machine-precision lockable. Surfacing `se_cr1_intercept`/`se_cr2_intercept` would add an unasserted, R-divergent public field, so intercept SEs are deliberately not exposed and are excluded from the parity lock (`tests/test_methodology_stacked_did.py`; SE-audit C1/(c) decision, 2026-07-09).
24342450
- **Note:** Survey weights compose multiplicatively with Q-weights for StackedDiD; only `weight_type="pweight"` (default) is supported — `fweight` and `aweight` are rejected because Q-weight composition changes weight semantics (non-integer for fweight, non-inverse-variance for aweight)
24352451

@@ -3068,11 +3084,13 @@ the finite-dimensional `p_0` is handled by the variance correction below.
30683084
`cluster=None` IS the per-unit IF variance by definition (see "IF-based
30693085
variance estimators vs analytical-sandwich estimators"); the inherited
30703086
value is family-consistent and truthful. No vocabulary widening.
3071-
- **Note:** M-080 position (auto-cluster policy) — DMLDiD's per-unit
3072-
influence-function variance IS unit-level clustering, so the 4.0
3073-
auto-cluster DEFAULT flip is inert for it — mirroring how CallawaySantAnna
3074-
is absent from M-080's `code_refs` and how M-010 pre-empted M-080 for the
3075-
TWFE event-study mode. COARSER-than-unit clustering ships via the CS-style
3087+
- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 -
3088+
category b in `docs/methodology/variance-conventions.md` "Default clustering
3089+
roster") — DMLDiD's per-unit influence-function variance IS unit-level
3090+
clustering, so the 4.0 auto-cluster DEFAULT flip is inert for it, as for
3091+
CallawaySantAnna and every other by-construction estimator; by decision it
3092+
carries no auto-cluster metadata (`cluster_name=None`) and the uniform 4.0
3093+
`cluster=False` off-switch is an accepted explicit no-op. COARSER-than-unit clustering ships via the CS-style
30763094
`cluster=` constructor parameter (3.11): the synthesized/injected
30773095
`SurveyDesign(psu=...)` drives PSU-cohesive folds, the per-cell CR1 SE,
30783096
the bootstrap draw structure and `df_inference`, while the moment kernels
@@ -6078,7 +6096,9 @@ equivalently `p <= alpha`.
60786096
would be unreachable) and the constant are library choices — the paper
60796097
declines to supply a cutoff (review lines 917-918) — and change no behavior.
60806098
- **Note:** M-080 unit auto-cluster is satisfied by construction: the bootstrap
6081-
resamples individuals. No `cluster=`, `vcov_type=`, survey design, covariates,
6099+
resamples individuals (decision recorded 2026-10-18: category d in
6100+
`docs/methodology/variance-conventions.md` - no `cluster` param and no cluster
6101+
metadata on the container). No `cluster=`, `vcov_type=`, survey design, covariates,
60826102
staggered adoption, censoring, or repeated cross-sections in this version
60836103
(the review's deferred extensions).
60846104
- **Note:** `DiagnosticReport` and `BusinessReport` reject `DurationDiDResults`
@@ -6841,6 +6861,12 @@ should be a deliberate user choice.
68416861

68426862
**Note:** This estimator is a **documented synthesis** of ingredients — no single published software package implements the exact recipe. `did2s` (R/Stata, Butts & Gardner 2022) implements the Gardner two-stage residualization but does NOT support ring covariates. The Butts (2021/2023) paper proposes the ring estimator in Equations 5/6/8 (non-staggered) and Section 5 / Table 2 (staggered) but does not ship reference software. The diff-diff implementation combines: (a) Butts (2021) Section 5 / Table 2 identification, (b) Gardner (2022) two-stage residualize-then-fit, (c) Wave A's Conley spatial-HAC vcov.
68436863

6864+
- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category a):
6865+
SpilloverDiD is the ONE required-unit estimator still running observation-level HC
6866+
when `cluster=` is omitted, so its default SE flips to unit clustering at 4.0 with
6867+
TWFE's carve-outs mirrored (explicit `cluster=` wins; Conley drops the auto-cluster;
6868+
never injected as a survey PSU). 3.x numbers are reproduced with `cluster=False`.
6869+
Roster in `docs/methodology/variance-conventions.md` "Default clustering roster".
68446870
- **Note (rank-guarded Wave D bread):** The Wave D variance bread `A_22 = (X_2'WX_2)^{-1}` is inverted by the shared `_rank_guarded_inv` (`diff_diff/linalg.py`) on the already-column-dropped kept submatrix. `np.linalg.solve` raised only on an exactly-singular bread (prior fallback: dense `lstsq`); a **near**-singular kept Gram would otherwise flow a garbage inverse (~1e13) into the SE. The rank-guard truncates redundant directions → finite SE on the identified subspace (NaN at rank 0), re-inflated to (k, k) with NaN at the dropped positions, and warns. See the CallawaySantAnna "rank-guarded IF standard errors" Note. Sibling of axis-A finding #17.
68456871

68466872
**Identification spec (committed):**

0 commit comments

Comments
 (0)