diff --git a/TODO.md b/TODO.md index de8553b1..6f36089a 100644 --- a/TODO.md +++ b/TODO.md @@ -80,7 +80,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m | 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 | +| 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 | ### Testing / docs diff --git a/changelog.d/20261018-v4-cut-framing.md b/changelog.d/20261018-v4-cut-framing.md new file mode 100644 index 00000000..7f246bf0 --- /dev/null +++ b/changelog.d/20261018-v4-cut-framing.md @@ -0,0 +1,29 @@ +### Documentation +- **4.0-cut framing** (the first PR of the 4.0 cut, at v3.13.0): `docs/v4-design.md` + re-synced with the library as shipped since 3.9 - the estimator roster now counts + LWDiD/DMLDiD/DurationDiD (27 -> 24 at 4.0, 23 estimators + Bacon in the diagnostic + family), the diagnostic family names the TWFE weights containers and + `DurationDiDPretestResults`, and section 9 carries the ten-PR cut sequence. `M-080` + (auto-cluster policy) re-scoped to the measured roster: static `TwoWayFixedEffects` + already auto-clusters at unit, `SpilloverDiD` is the one estimator whose default SE + moves at 4.0, `cluster=False` becomes the uniform explicit off-switch, and the + by-construction estimators keep no auto-cluster metadata by decision (new "Default + clustering roster" section in `docs/methodology/variance-conventions.md`; REGISTRY + Notes on SpilloverDiD, StackedDiD, ImputationDiD, TwoStageDiD, DMLDiD, DurationDiD). + `M-008` (`DIFF_DIFF_SOLVE_OLS_FASTPATH` default) decided: evaluated, kept off. New + ledger row `M-150` gives `WooldridgeDiDResults.to_dataframe(level="event")` its own + locator (twin of `M-136`). The migration guide's auto-cluster section, appendix and + orientation table follow the ledger (111 appendix rows). + +### Internal +- **Ledger cut window** (`docs/v4-deprecations.yaml` header `cut_window: "4.0"`, + `cut_base: "3.13.0"`; spec section 11): rows scheduled at 4.0 may flip terminal + ahead of the version bump so the removals land over many PRs while `__version__` + stays 3.13.0; `tests/test_v4_matrix.py` relaxes only the early-removal guard for the + window version, refuses any other version bump while early-terminal rows exist, and + keeps due enforcement unchanged. The id snapshot and `ROW_COUNT_FLOOR` now include + `M-148`/`M-149` (shipped by #838 without extending them) and `M-150` (132 rows). + `M-091`'s roster records the four later-born `Diagnostic` containers; `M-050`'s + `code_refs` record that `diff_diff/_deprecation.py` survives 4.0 as 5.0-window + infrastructure; `M-045`/`M-115` name the legacy-CR1 remap branches their removal + deletes. A `maint/3.13` branch is cut at `v3.13.0` for 3.13.x patches. diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py index 50989bc4..3ea91d8a 100644 --- a/diff_diff/_deprecation.py +++ b/diff_diff/_deprecation.py @@ -2,8 +2,11 @@ This module is a deliberate LEAF: it imports only the standard library, never other ``diff_diff`` modules, so every estimator module can import it without -cycles (the ``_base.py`` precedent). It is private and unexported; the 4.0 -removal PR deletes the shims that use it. +cycles (the ``_base.py`` precedent). It is private and unexported. The 4.0 +removal PRs delete the 3.9 shims that use it, but the module itself SURVIVES +4.0: the 4.0 -> 5.0 ``FutureWarning`` property shims of the results-storage +flip (ledger rows M-016 and M-050..M-058) ride ``deprecated_field_property`` +through the 5.0 window (recorded in M-050's ``code_refs``). The helpers implement the ledger's shim contract (``docs/v4-deprecations.yaml``, groups ``renames-*``; rules in ``docs/v4-design.md`` section 8): diff --git a/diff_diff/wooldridge_results.py b/diff_diff/wooldridge_results.py index c03ceaf5..5f6374e2 100644 --- a/diff_diff/wooldridge_results.py +++ b/diff_diff/wooldridge_results.py @@ -899,7 +899,7 @@ def to_dataframe( level : "simple" | "group" | "calendar" | "event_study" | "gt" Use "gt" to export raw group-time effects. ("event" is a deprecated spelling of "event_study"; warns and is rejected - in 4.0 - row M-086.) + in 4.0 - row M-150, the to_dataframe twin of M-086.) aggregation : str, optional Deprecated alias for ``level`` (row M-044); warns with ``FutureWarning`` and will be removed in 4.0. diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 62bc2e2a..874b7079 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -2150,6 +2150,11 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only. - Bootstrap does not update pre-period SEs (they are from the lead regression) - 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 - Only affects event study aggregation; overall ATT and group aggregation unchanged +- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c2): + the default `cluster_var = unit` lives INSIDE the unit-level influence-function + variance, so no unclustered variance exists; the uniform 4.0 `cluster=False` + off-switch is an accepted explicit no-op (not an error, not a new SE). Roster in + `docs/methodology/variance-conventions.md` "Default clustering roster". - **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. - **Note (pretrends lead-regression tail df, 3.9 / M-127):** the per-lead `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. **Primary source:** [Gardner, J. (2022). Two-stage differences in differences. arXiv:2207.05943.](https://arxiv.org/abs/2207.05943) +- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c2): + as for ImputationDiD, the default `cluster_var = unit` is internal to the unit-level + influence-function machinery, so the uniform 4.0 `cluster=False` off-switch is an + accepted explicit no-op. Roster in `docs/methodology/variance-conventions.md`. - **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. **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 - [x] Anticipation parameter support - [x] Never-treated encoding (0 and inf) - [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). +- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c3 + in `docs/methodology/variance-conventions.md` "Default clustering roster"): the + stacked regression clusters by construction and `cluster` accepts only `'unit'` / + `'unit_subexp'`; the uniform 4.0 `cluster=False` off-switch is therefore REJECTED + here with the existing validator message rather than yielding an unclustered SE + (no such variance is defined for the stacked design). The spec's "becomes an + instance of the general policy" applies to the default resolution only. - **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). - **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) @@ -3068,11 +3084,13 @@ the finite-dimensional `p_0` is handled by the variance correction below. `cluster=None` IS the per-unit IF variance by definition (see "IF-based variance estimators vs analytical-sandwich estimators"); the inherited value is family-consistent and truthful. No vocabulary widening. -- **Note:** M-080 position (auto-cluster policy) — DMLDiD's per-unit - influence-function variance IS unit-level clustering, so the 4.0 - auto-cluster DEFAULT flip is inert for it — mirroring how CallawaySantAnna - is absent from M-080's `code_refs` and how M-010 pre-empted M-080 for the - TWFE event-study mode. COARSER-than-unit clustering ships via the CS-style +- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - + category b in `docs/methodology/variance-conventions.md` "Default clustering + roster") — DMLDiD's per-unit influence-function variance IS unit-level + clustering, so the 4.0 auto-cluster DEFAULT flip is inert for it, as for + CallawaySantAnna and every other by-construction estimator; by decision it + carries no auto-cluster metadata (`cluster_name=None`) and the uniform 4.0 + `cluster=False` off-switch is an accepted explicit no-op. COARSER-than-unit clustering ships via the CS-style `cluster=` constructor parameter (3.11): the synthesized/injected `SurveyDesign(psu=...)` drives PSU-cohesive folds, the per-cell CR1 SE, the bootstrap draw structure and `df_inference`, while the moment kernels @@ -6078,7 +6096,9 @@ equivalently `p <= alpha`. would be unreachable) and the constant are library choices — the paper declines to supply a cutoff (review lines 917-918) — and change no behavior. - **Note:** M-080 unit auto-cluster is satisfied by construction: the bootstrap - resamples individuals. No `cluster=`, `vcov_type=`, survey design, covariates, + resamples individuals (decision recorded 2026-10-18: category d in + `docs/methodology/variance-conventions.md` - no `cluster` param and no cluster + metadata on the container). No `cluster=`, `vcov_type=`, survey design, covariates, staggered adoption, censoring, or repeated cross-sections in this version (the review's deferred extensions). - **Note:** `DiagnosticReport` and `BusinessReport` reject `DurationDiDResults` @@ -6841,6 +6861,12 @@ should be a deliberate user choice. **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. +- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category a): + SpilloverDiD is the ONE required-unit estimator still running observation-level HC + when `cluster=` is omitted, so its default SE flips to unit clustering at 4.0 with + TWFE's carve-outs mirrored (explicit `cluster=` wins; Conley drops the auto-cluster; + never injected as a survey PSU). 3.x numbers are reproduced with `cluster=False`. + Roster in `docs/methodology/variance-conventions.md` "Default clustering roster". - **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. **Identification spec (committed):** diff --git a/docs/methodology/variance-conventions.md b/docs/methodology/variance-conventions.md index 79aff413..1f90769a 100644 --- a/docs/methodology/variance-conventions.md +++ b/docs/methodology/variance-conventions.md @@ -178,3 +178,50 @@ locked #663 direction); the resolver is `diff_diff.utils.resolve_tail_df`, and the out-of-scope roster (IF-based estimators, TripleDifference/TROP, Wooldridge GLM arms) is enumerated in the REGISTRY TwoWayFixedEffects deviation note. + +## Default clustering roster (decided 2026-10-18, [M-080]) + +Measured by execution at v3.13.0 for the 4.0-cut framing (50 units x 8 periods, +`cluster=` omitted vs `cluster="unit"`). The ledger row M-080 was written on the +premise that static `TwoWayFixedEffects` runs HC-robust by default; it has +auto-clustered at unit since 2026-01, so the row was re-scoped to this roster +(spec `docs/v4-design.md` section 7). Categories: **a** default SE moves at +4.0; **b** unit-level by construction with a `cluster` param; **c1** already +clusters at unit by a regression-level default; **c2** defaults to unit inside +unit-level influence-function machinery; **c3** clusters by construction with a +restricted `cluster` vocabulary; **d** no `cluster` param; **n/a** +cross-sectional or no clustering support. + +| Class | `cluster` param? | Default when omitted (v3.13.0) | `cluster=False` at 4.0 | Category | +|---|---|---|---|---| +| SpilloverDiD | yes | observation-level HC1 | plain HC SE (= 3.x default) | **a** - default flips to unit | +| TwoWayFixedEffects (static) | yes | CR1 at unit (`cluster_name="unit"`, `n_clusters=None` on the analytical path - gap closed at 4.0) | plain HC sandwich (new behaviour) | c1 | +| TwoWayFixedEffects (event-study) | yes | CR1 at unit since 3.9 (M-010) | plain HC sandwich (new behaviour) | c1 | +| SunAbraham | yes | CR1 at unit (reports `cluster_name=None`, no `n_clusters` field - gap closed at 4.0) | plain HC sandwich (new behaviour) | c1 | +| WooldridgeDiD | yes | CR1 at unit (`"unit"`, G) | plain HC sandwich (new behaviour) | c1 | +| LPDiD | yes | CR1 at unit (`self.cluster or unit`; `False` currently treated as auto-cluster - bug fixed at 4.0) | plain HC sandwich (new behaviour) | c1 | +| ImputationDiD | yes | `cluster_var = unit` inside the unit-level IF variance (`"unit"`, G) | accepted explicit no-op | c2 | +| TwoStageDiD | yes | `cluster_var = unit` inside the unit-level IF variance (`"unit"`, G) | accepted explicit no-op | c2 | +| StackedDiD | yes (`'unit'`/`'unit_subexp'` only) | CR1 at unit (`"unit"`, G) | REJECTED (validator) | c3 | +| CallawaySantAnna | yes | per-unit influence functions (no G/(G-1)); `cluster_name=None` | accepted explicit no-op | b | +| EfficientDiD | yes | per-unit EIF (no G/(G-1)); `cluster_name=None` | accepted explicit no-op | b | +| DMLDiD | yes | per-unit IF (inherits CS); `cluster_name=None` | accepted explicit no-op | b | +| ChaisemartinDHaultfoeuille | yes (any non-None raises) | group-level IF + multiplier bootstrap; no cluster fields | accepted explicit no-op | b | +| TripleDifference (staggered mode) | yes (`cluster=` raises; bootstrap required) | per-unit IF; no cluster fields | accepted explicit no-op | b | +| LWDiD | yes | HC1 on the collapsed unit cross-section (n_obs = n_units); `cluster_name=None` | accepted explicit no-op | b | +| HeterogeneousAdoptionDiD | yes | HC1/CCT on unit-level first differences; `cluster_name=None` | accepted explicit no-op | b | +| ContinuousDiD | no | per-unit IF; no cluster fields | - | d | +| DurationDiD | no | unit-history bootstrap; no cluster fields | - | d | +| SyntheticDiD, SyntheticControl, TROP, ChangesInChanges | no | native variance methods | - | d | +| DifferenceInDifferences | yes (`unit` optional) | HC1 (2x2 stays HC-robust by spec) | plain HC (= default) | n/a | +| TripleDifference (2x2x2 mode) | yes (`unit` not accepted) | HC1 | plain HC (= default) | n/a | +| RegressionDiscontinuity, RDPlot, RDDensityTest | no | no clustering support | - | n/a | + +**Decision (2026-10-18, user):** categories b and d carry NO auto-cluster +metadata - `cluster_name` stays `None` where the field exists and no field is +added where it does not. Their SE formulas lack the G/(G-1) factor the explicit +`cluster="unit"` path applies (verified: CS and EDiD `se(cluster="unit")^2 / +se(default)^2 = 50/49` on the probe), so labelling them `"unit"` would misstate +the formula. The 4.0 flip is therefore the SpilloverDiD default, the uniform +`cluster=False` off-switch on categories a/b/c1/c2, and the metadata gaps named +in the c1 rows. diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md index 40bfbd63..abd94e17 100644 --- a/docs/migration-4.0.md +++ b/docs/migration-4.0.md @@ -29,12 +29,12 @@ updated, CI fails. | obligation-sdid-params | Two SyntheticDiD constructor params (inert since 3.0.0) are removed | 3 | §7b | | obligation-sc-inner-budget | Two SyntheticControl constructor params (inert since the exact NNLS inner solve) warn at 4.0 and are removed at 5.0 | 2 | §7b | | obligation-warning-retirements | A transition `FutureWarning` stops being emitted | 1 | §7b | -| policy-auto-cluster | TwoWayFixedEffects auto-clusters on `unit` unless you pass `cluster=` | 1 | §6 | +| policy-auto-cluster | SpilloverDiD auto-clusters on `unit` unless you pass `cluster=`; `cluster=False` becomes the uniform explicit off-switch | 1 | §6 | | renames-cohort | `cohort=` becomes `first_treat=` | 1 | §3 | | renames-col-suffix | The `*_col` suffix is dropped across 27 parameters and one field | 27 | §3 | | renames-control-group | `clean_control=` becomes `control_group=` | 2 | §3 | | renames-dcdh | dCDH's `group=`/`controls=` become `unit=`/`covariates=` | 4 | §3 | -| renames-level | Aggregation-level params and their accepted values unify on `level=`/`"event_study"` | 4 | §3 | +| renames-level | Aggregation-level params and their accepted values unify on `level=`/`"event_study"` | 5 | §3 | | renames-post | The post-dummy `time=` becomes `post=` | 3 | §3 | | renames-robust-drop | `robust=` is dropped in favour of `vcov_type=` - read §3, the translation is per-estimator | 4 | §3 | | results-contract | A legacy sentinel field is retired in favour of the unified event-study surface | 1 | §5 | @@ -206,12 +206,24 @@ Two changes alter results without changing any call: - **`df_convention` flips to `"cluster"`** on seven estimators. Pass `df_convention="residual"` to reproduce 3.x numbers exactly. -- **Panel estimators auto-cluster at `unit`** when `cluster=` is omitted. Pass - **`cluster=False`** to disable it — `None` and omission both mean "auto-cluster", so - `cluster=None` does *not* reproduce unclustered 3.x standard errors. Cross-sectional - 2x2 estimators stay HC-robust unless you pass `cluster=`. Note the TWFE **event-study** - mode has auto-clustered since 3.9 (it shipped that way with the merge), so this flip - changes the static and other panel paths, not that one. +- **`SpilloverDiD` auto-clusters at `unit`** when `cluster=` is omitted — it is the one + estimator whose default standard errors move. Pass **`cluster=False`** for the 3.x + observation-level HC numbers; `None` and omission both mean "auto-cluster", so + `cluster=None` does *not* reproduce them. No other default moves: static and event-study + `TwoWayFixedEffects`, `SunAbraham`, `WooldridgeDiD`, `ImputationDiD`, `TwoStageDiD`, + `LPDiD` and `StackedDiD` already clustered at `unit` in 3.x, and the influence-function + and unit-cross-section estimators (`CallawaySantAnna`, `EfficientDiD`, `DMLDiD`, + `ChaisemartinDHaultfoeuille`, `LWDiD`, `HeterogeneousAdoptionDiD`, the staggered + `TripleDifference` mode) are unit-level by construction and keep reporting no cluster + metadata. Cross-sectional 2x2 estimators stay HC-robust unless you pass `cluster=`. + `cluster=False` becomes the explicit off-switch on every estimator whose clustering + is a choice (in 3.x it was accepted nowhere): on `SpilloverDiD`, `TwoWayFixedEffects`, + `SunAbraham`, `WooldridgeDiD` and `LPDiD` it yields the plain HC sandwich — on the + last four that is **new** unclustered behaviour, not a 3.x reproduction; on the + by-construction estimators and on `ImputationDiD`/`TwoStageDiD` it is an accepted + no-op (their variance is unit-level by construction, so nothing can be switched off); + `StackedDiD` keeps rejecting it (its `cluster` accepts only `"unit"`/`"unit_subexp"`, + the stacked regression clusters by construction). ## Removed functions and aliases @@ -264,9 +276,21 @@ and [M-147] is an additive policy. These have no removal/deprecation fields and dead no-op (it prints no alpha-based interval; the displayed confidence set is keyed on its own stored `gamma`), and a non-fit value now raises like the rest of the family. -One pending decision: the `DIFF_DIFF_SOLVE_OLS_FASTPATH` environment default has a go/no-go due -at 4.0 that has not been made. If it lands on, it is a numerics change and will be documented -then; "evaluated, kept off" is an equally valid outcome, so it carries no appendix row today. +The `DIFF_DIFF_SOLVE_OLS_FASTPATH` environment default was due a go/no-go at 4.0 ([M-008]): +decided 2026-10-18 as **evaluated, kept off**. No numerics change — the opt-in fast path +behaves exactly as in 3.x, and a later flip would be a major-version change on its own +schedule. It therefore carries no appendix row. + +### Estimators added after 3.9 + +`LWDiD` (3.10), `DMLDiD` (3.11), `DurationDiD` (3.12), `RDDensityTest` and the TWFE weights +family (`attgt_weights`, `decompose_twfe_weights`, 3.13) were born on the 4.0 vocabulary, so +none of the renames above touches them. The `df_convention` flip does not touch them either: +they expose no such knob. `DMLDiDResults` inherits `CallawaySantAnnaResults` storage, so its +`overall_att` family rides [M-050] with the parent. `LWDiDResults` gains the uniform +`summary(alpha=None)` and `to_dataframe(level=...)` signatures in the conformance PR that +precedes the removals, which also schedules the deprecation of its `pvalue`/`ci` alias +properties (see the ledger rows added there; the aliases are silent today). ## Codemod @@ -325,7 +349,7 @@ but now emit a `FutureWarning`, and they no longer appear in `dir()`/`vars()`. ## Appendix: every 4.0 change -One row per ledger row that requires action at 4.0 — 108 in total. `Old` and `New` are the +One row per ledger row that requires action at 4.0 — 111 in total. `Old` and `New` are the ledger's own locators. An em dash in `New` means the ledger names no successor **locator**; it does not mean no action is required, so read the `Fix` cell. @@ -394,7 +418,7 @@ does not mean no action is required, so read the `Fix` cell. | M-002 | obligation-sdid-params | `diff_diff:SyntheticDiD[zeta]` | — | Drop it. It has been IGNORED since 3.0.0, so 3.x already auto-computes regularization - do NOT copy its value into `zeta_lambda=`, which activates an override that was inert and changes weights, ATT and inference. Set `zeta_lambda=` only as a deliberate new choice. | | M-003 | obligation-sdid-params | `diff_diff:SyntheticDiDResults.placebo_effects` | `diff_diff:SyntheticDiDResults.variance_effects` | Read `variance_effects` instead of `placebo_effects`. | | M-007 | obligation-warning-retirements | `diff_diff.estimators` | — | The MultiPeriodDiD `e=-1` transition FutureWarning stops being emitted; drop any warning filter that suppressed it. | -| M-080 | policy-auto-cluster | `diff_diff:TwoWayFixedEffects[cluster]` | — | Panel estimators auto-cluster at `unit` when `cluster=` is omitted; pass `cluster=False` to disable it and keep unclustered 3.x standard errors (`None`/omission means auto-cluster, not off). | +| M-080 | policy-auto-cluster | `diff_diff:SpilloverDiD[cluster]` | — | `SpilloverDiD` now clusters at `unit` when `cluster=` is omitted; pass `cluster=False` for the 3.x observation-level HC standard errors (`None`/omission means auto-cluster, not off). Every other panel estimator already clustered at unit or is unit-level by construction, so its numbers do not move. `cluster=False` is also newly accepted on `TwoWayFixedEffects`/`SunAbraham`/`WooldridgeDiD`/`LPDiD` (plain HC sandwich - new behaviour) and as a no-op on the by-construction estimators and `ImputationDiD`/`TwoStageDiD`; `StackedDiD` still rejects it. | | M-032 | renames-cohort | `diff_diff:WooldridgeDiD.fit[cohort]` | `diff_diff:WooldridgeDiD.fit[first_treat]` | Rename the keyword: `cohort=` becomes `first_treat=`. | | M-035 | renames-col-suffix | `diff_diff:HeterogeneousAdoptionDiD.fit[outcome_col]` | `diff_diff:HeterogeneousAdoptionDiD.fit[outcome]` | Rename the keyword: `outcome_col=` becomes `outcome=`. | | M-036 | renames-col-suffix | `diff_diff:HeterogeneousAdoptionDiD.fit[dose_col]` | `diff_diff:HeterogeneousAdoptionDiD.fit[dose]` | Rename the keyword: `dose_col=` becomes `dose=`. | @@ -433,6 +457,7 @@ does not mean no action is required, so read the `Fix` cell. | M-086 | renames-level | `diff_diff:WooldridgeDiDResults.aggregate[type]=event` | `diff_diff:WooldridgeDiDResults.aggregate[type]=event_study` | Change the accepted value: `"event"` becomes `"event_study"`. | | M-087 | renames-level | `diff_diff:WooldridgeDiDResults.summary[aggregation]` | — | `summary()` unifies to the library-wide `summary(alpha=None)` signature - drop `aggregation=` and select the level on `aggregate()` instead. | | M-136 | renames-level | `diff_diff:LPDiDResults.to_dataframe[level]=event` | `diff_diff:LPDiDResults.to_dataframe[level]=event_study` | Change the accepted value: `"event"` becomes `"event_study"`. | +| M-150 | renames-level | `diff_diff:WooldridgeDiDResults.to_dataframe[level]=event` | `diff_diff:WooldridgeDiDResults.to_dataframe[level]=event_study` | Change the accepted value: `"event"` becomes `"event_study"`. | | M-030 | renames-post | `diff_diff:DifferenceInDifferences.fit[time]` | `diff_diff:DifferenceInDifferences.fit[post]` | Rename the keyword: `time=` becomes `post=`. | | M-137 | renames-post | `diff_diff:permutation_test[time]` | `diff_diff:permutation_test[post]` | Rename the keyword: `time=` becomes `post=`. | | M-138 | renames-post | `diff_diff:leave_one_out_test[time]` | `diff_diff:leave_one_out_test[post]` | Rename the keyword: `time=` becomes `post=`. | diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index 278258ba..35fb11d3 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -11,15 +11,26 @@ # 3.9/4.0 surface that intentionally does NOT exist at HEAD; the enforcement # test asserts their ABSENCE until the owning phase ships. # -# FORMAT CONTRACT (parsed by a purpose-built scanner, no PyYAML): top-level -# `rows:` list; each row a flat mapping of single-line scalars; `code_refs` as -# an inline flow list; no anchors, no multi-line strings. Line-leading comments -# only (a trailing `#` after a value is parsed as part of the value). +# FORMAT CONTRACT (parsed by a purpose-built scanner, no PyYAML): an optional +# HEADER of single-line version scalars above `rows:` (today the cut-window +# pair `cut_window`/`cut_base`, declared together or not at all - spec +# section 11 "Cut window"); then the top-level `rows:` list; each row a flat +# mapping of single-line scalars; `code_refs` as an inline flow list; no +# anchors, no multi-line strings. Line-leading comments only (a trailing `#` +# after a value is parsed as part of the value). # # Locator grammar: "diff_diff:Name" (top-level export), "diff_diff:Class[param]" # (__init__ param), "diff_diff:Class.method[param]" (method param), # "diff_diff:Class.attr" (field/property). Module part must always import. +# CUT WINDOW (declared 2026-10-18 at the 4.0-cut framing): rows scheduled at +# `cut_window` may flip terminal ahead of the version bump so the removals land +# over many PRs; `__version__` must stay at `cut_base` until the release PR bumps +# to `cut_window` (any other bump fails tests/test_v4_matrix.py's off-base +# guard). The keys stay as a historical record once the window has closed. +cut_window: "4.0" +cut_base: "3.13.0" + rows: # ---- Pre-existing obligations (repo sweep 2026-07-18) -------------------- - id: M-001 @@ -119,12 +130,14 @@ rows: new: null deprecated_in: null removed_in: null - status: evaluate + status: done phase: 5 env_var: DIFF_DIFF_SOLVE_OLS_FASTPATH decision_due: "4.0" + decided_default: off + test_ref: tests/test_linalg.py code_refs: [diff_diff/linalg.py] - notes: "DIFF_DIFF_SOLVE_OLS_FASTPATH default-ON is a v4-class change GATED on golden recapture + certification telemetry soak (TODO.md row); record go/no-go at the 4.0 cut either way. SOAK WINDOW = the 3.9 -> 4.0 gap: opt-in telemetry can only accumulate from a release that advertises the env var, so the 3.9 release notes are the last surface that can start it. A short gap is a legitimate outcome - 'evaluated, kept off' is terminal and needs no soak - but that is a scheduling decision to make deliberately, not to discover at the cut." + notes: "DECIDED 2026-10-18 (4.0-cut framing, user decision): evaluated, KEPT OFF. No opt-in telemetry was ever collected in the 3.9 -> 3.13 gap and the golden recapture never ran, so there is no evidence base for a flip; 'evaluated, kept off' is the terminal state this row always allowed. The TODO.md fastpath row stays as a 4.x re-evaluation (a later flip is a major-version change on its own schedule). test_ref: the env-resolver convention suite in tests/test_linalg.py asserts _SOLVE_OLS_FASTPATH is False with the env var unset. ORIGINAL NOTE: DIFF_DIFF_SOLVE_OLS_FASTPATH default-ON is a v4-class change GATED on golden recapture + certification telemetry soak (TODO.md row); record go/no-go at the 4.0 cut either way. SOAK WINDOW = the 3.9 -> 4.0 gap: opt-in telemetry can only accumulate from a release that advertises the env var, so the 3.9 release notes are the last surface that can start it. A short gap is a legitimate outcome - 'evaluated, kept off' is terminal and needs no soak - but that is a scheduling decision to make deliberately, not to discover at the cut." # ---- The three merges + retiring classes --------------------------------- - id: M-010 @@ -580,7 +593,7 @@ rows: warning: FutureWarning test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/estimators.py, docs/methodology/REGISTRY.md, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, diff_diff/synthetic_did.py] - notes: "Redundant with vcov_type; inherited by TwoWayFixedEffects (and MultiPeriodDiD until [M-010])." + notes: "Redundant with vcov_type; inherited by TwoWayFixedEffects (and MultiPeriodDiD until [M-010]). REMOVAL SCOPE (2026-10-18): the 4.0 removal also deletes the legacy-CR1 remap branch in estimators.py _resolve_effective_vcov_type (reachable only via an explicit robust=False that resolved vcov_type to 'classical'); _vcov_type_explicit itself STAYS - the TWFE/SunAbraham/WooldridgeDiD auto-cluster carve-outs read it." - id: M-046 kind: param group: renames-robust-drop @@ -626,8 +639,8 @@ rows: phase: 5 warning: FutureWarning test_ref: null - code_refs: [diff_diff/staggered_results.py, diff_diff/staggered.py, diff_diff/staggered_aggregation.py, diff_diff/power.py, diff_diff/practitioner.py, diff_diff/efficient_did.py, diff_diff/wooldridge.py, diff_diff/_reporting_helpers.py, diff_diff/diagnostic_report.py, diff_diff/prep_dgp.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/staggered_triple_diff_results.py, diff_diff/dml_did.py, diff_diff/dml_did_results.py, diff_diff/guides/llms-autonomous.txt, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, docs/methodology/REGISTRY.md, docs/methodology/REPORTING.md] - notes: "Pickle migration via __setstate__ per spec section 5. This row carries the token family's CROSS-CUTTING consumer inventory (reports, guides, methodology docs, DGP helpers, the un-rowed staggered-DDD engine/container) alongside its own producers; per-class producer files live on their own rows. Inventory completed at the 3.9 bump, when the rule-11 Duty C window armed for 4.0-scheduled rows." + code_refs: [diff_diff/staggered_results.py, diff_diff/staggered.py, diff_diff/staggered_aggregation.py, diff_diff/power.py, diff_diff/practitioner.py, diff_diff/efficient_did.py, diff_diff/wooldridge.py, diff_diff/_reporting_helpers.py, diff_diff/diagnostic_report.py, diff_diff/prep_dgp.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/staggered_triple_diff_results.py, diff_diff/dml_did.py, diff_diff/dml_did_results.py, diff_diff/_deprecation.py, diff_diff/guides/llms-autonomous.txt, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, docs/methodology/REGISTRY.md, docs/methodology/REPORTING.md] + notes: "diff_diff/_deprecation.py is 5.0-WINDOW INFRASTRUCTURE (added 2026-10-18): this row's FutureWarning properties and [M-016]'s ride its deprecated_field_property helper through the 4.0 -> 5.0 window, so the 4.0 removal PRs delete the shims that USE it but never the module. Pickle migration via __setstate__ per spec section 5. This row carries the token family's CROSS-CUTTING consumer inventory (reports, guides, methodology docs, DGP helpers, the un-rowed staggered-DDD engine/container) alongside its own producers; per-class producer files live on their own rows. Inventory completed at the 3.9 bump, when the rule-11 Duty C window armed for 4.0-scheduled rows." - id: M-051 kind: field group: field-flip @@ -988,8 +1001,8 @@ rows: status: done phase: 2 test_ref: tests/test_diagnostic_marker.py - code_refs: [diff_diff/results_base.py, diff_diff/bacon.py, diff_diff/rdplot.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/power.py, diff_diff/diagnostics.py, diff_diff/had_pretests.py, diff_diff/diagnostic_report.py, diff_diff/business_report.py, diff_diff/practitioner.py, diff_diff/_reporting_helpers.py, diff_diff/__init__.py] - notes: "Phase 2 Diagnostic marker base on the RESULT roster (spec section 3.5): BaconDecompositionResults, RDPlotResult, HonestDiDResults + SensitivityResults, PreTrendsPowerResults + PreTrendsPowerCurve, PowerResults + Simulation*Results, PlaceboTestResults, and the HAD pretest containers (QUGTestResults, StuteTestResults, YatchewTestResults, StuteJointResult, HADPretestReport), and DiagnosticReportResults (serialization pair moved from the builder onto the container). Flip to done ALSO requires consumer-propagation tests: BusinessReport rejects marked diagnostics as primary estimator input BY TYPE (not by name); practitioner_next_steps routes marked diagnostics through diagnostic handling (not the unknown-result estimator fallback); and DiagnosticReport routes by the marker - at least one non-Bacon marked result handled as a diagnostic, never via estimator fallback, with Bacon's existing read-out retained and tested. Flip to done requires the dedicated roster test (isinstance + summary/to_dataframe on every member; estimator results NOT marked). Raw-dict functions and TWFEWeightsResult are docs-family only (narrowed, spec section 3.5). introduced_in gates the 3.9 cut: the marker cannot be silently skipped. Marker lives in diff_diff/results_base.py; done in this introducing diff." + code_refs: [diff_diff/results_base.py, diff_diff/bacon.py, diff_diff/rdplot.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/power.py, diff_diff/diagnostics.py, diff_diff/had_pretests.py, diff_diff/diagnostic_report.py, diff_diff/business_report.py, diff_diff/practitioner.py, diff_diff/_reporting_helpers.py, diff_diff/__init__.py, diff_diff/rddensity.py, diff_diff/duration_did_results.py, diff_diff/twfe_weights_results.py] + notes: "ROSTER ADDENDUM 2026-10-18 (4.0-cut framing): four containers born marked after this row went done - RDDensityTestResult (3.13), DurationDiDPretestResults (3.12), ATTGTWeightsResult + TWFEDecompositionResult (3.13) - are enrolled in tests/test_diagnostic_marker.py; recorded here so the roster and code_refs match the tree. Phase 2 Diagnostic marker base on the RESULT roster (spec section 3.5): BaconDecompositionResults, RDPlotResult, HonestDiDResults + SensitivityResults, PreTrendsPowerResults + PreTrendsPowerCurve, PowerResults + Simulation*Results, PlaceboTestResults, and the HAD pretest containers (QUGTestResults, StuteTestResults, YatchewTestResults, StuteJointResult, HADPretestReport), and DiagnosticReportResults (serialization pair moved from the builder onto the container). Flip to done ALSO requires consumer-propagation tests: BusinessReport rejects marked diagnostics as primary estimator input BY TYPE (not by name); practitioner_next_steps routes marked diagnostics through diagnostic handling (not the unknown-result estimator fallback); and DiagnosticReport routes by the marker - at least one non-Bacon marked result handled as a diagnostic, never via estimator fallback, with Bacon's existing read-out retained and tested. Flip to done requires the dedicated roster test (isinstance + summary/to_dataframe on every member; estimator results NOT marked). Raw-dict functions and TWFEWeightsResult are docs-family only (narrowed, spec section 3.5). introduced_in gates the 3.9 cut: the marker cannot be silently skipped. Marker lives in diff_diff/results_base.py; done in this introducing diff." - id: M-092 kind: behavior group: results-contract @@ -1019,14 +1032,14 @@ rows: - id: M-080 kind: behavior group: policy-auto-cluster - old: "diff_diff:TwoWayFixedEffects[cluster]" + old: "diff_diff:SpilloverDiD[cluster]" new: null deprecated_in: "4.0" removed_in: null status: planned phase: 5 - code_refs: [diff_diff/estimators.py, diff_diff/twfe.py, diff_diff/stacked_did.py] - notes: "ONE auto-cluster policy at 4.0: panel estimators (required unit column) default to clustering at unit with cluster_name/n_clusters metadata; cluster=False disables; 2x2 cross-sectional estimators stay HC-robust. Changes some default SEs - migration guide with [M-004..M-006]. SCOPE NOTE (Phase 3(a), user decision 2026-08-07): the TWFE EVENT-STUDY mode shipped already auto-clustered at 3.9 (new API adopts the end-state immediately, with the static carve-outs mirrored - see [M-010]), so this 4.0 flip does not re-touch it." + code_refs: [diff_diff/spillover.py, diff_diff/twfe.py, diff_diff/estimators.py, diff_diff/sun_abraham.py, diff_diff/lpdid.py, diff_diff/stacked_did.py, diff_diff/wooldridge.py, diff_diff/imputation.py, diff_diff/two_stage.py, diff_diff/staggered.py, diff_diff/efficient_did.py, diff_diff/dml_did.py, diff_diff/lwdid.py, diff_diff/had.py, diff_diff/triple_diff.py, diff_diff/staggered_triple_diff.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/chaisemartin_dhaultfoeuille.py, docs/methodology/variance-conventions.md] + notes: "RE-SCOPED 2026-10-18 (4.0-cut framing, user decision; measured roster in docs/methodology/variance-conventions.md and spec section 7). The original premise - static TwoWayFixedEffects runs HC-robust by default - was already false: static TWFE auto-clusters at unit (twfe.py, since 2026-01). Measured categories: (a) SpilloverDiD is the ONE required-unit estimator whose default SE moves (observation-level HC -> unit clustering, mirroring TWFE's carve-outs: explicit cluster= wins, Conley drops it, never injected as survey PSU); (c1) TWFE/SunAbraham/WooldridgeDiD/LPDiD already cluster at unit by regression-level default - cluster=False yields the plain HC sandwich; (c2) ImputationDiD/TwoStageDiD default cluster_var=unit INSIDE unit-level influence-function machinery - no unclustered variance exists, cluster=False is an accepted explicit no-op; (c3) StackedDiD clusters by construction and its validator accepts only unit/unit_subexp - cluster=False is REJECTED (REGISTRY Note); (b) CS/EfficientDiD/DMLDiD/dCDH/staggered-DDD (per-unit IF) and LWDiD/HAD (regression on a unit-level cross-section) are unit-level by construction - cluster=False is an accepted explicit no-op; (d) ContinuousDiD/DurationDiD/SDiD/SC/TROP/CiC have no cluster param; (n/a) cross-sectional DiD/2x2x2 DDD/RDD stay HC-robust or unclustered. Categories b and d carry NO auto-cluster metadata by decision (cluster_name stays None where the field exists; no field is added where it does not). The 4.0 flip therefore = the SpilloverDiD default + the uniform cluster=False off-switch on a/b/c1/c2 (today accepted nowhere: LPDiD's `self.cluster or unit` treats False as auto-cluster, most others look up a column named False, DMLDiD/StackedDiD reject on type, dCDH raises on any non-None) + metadata gaps (TWFE/DiD n_clusters on the analytical path; SunAbraham cluster_name + n_clusters). ORIGINAL NOTE: ONE auto-cluster policy at 4.0: panel estimators (required unit column) default to clustering at unit with cluster_name/n_clusters metadata; cluster=False disables; 2x2 cross-sectional estimators stay HC-robust. Changes some default SEs - migration guide with [M-004..M-006]. SCOPE NOTE (Phase 3(a), user decision 2026-08-07): the TWFE EVENT-STUDY mode shipped already auto-clustered at 3.9 (new API adopts the end-state immediately, with the static carve-outs mirrored - see [M-010]), so this 4.0 flip does not re-touch it." - id: M-081 kind: behavior group: policy-n-bootstrap @@ -1374,7 +1387,7 @@ rows: warning: FutureWarning test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/linalg.py, diff_diff/sun_abraham.py] - notes: "Fourth site of the 'robust' drop - spec section 7 says the flag dies 'everywhere it exists' but [M-045]..[M-047] enumerated only three. LinearRegression is a top-level export with its OWN __init__ (the TWFE/MultiPeriodDiD hits inherit DifferenceInDifferences.__init__ and ride [M-045]). Redundant with vcov_type, same as its siblings." + notes: "Fourth site of the 'robust' drop - spec section 7 says the flag dies 'everywhere it exists' but [M-045]..[M-047] enumerated only three. LinearRegression is a top-level export with its OWN __init__ (the TWFE/MultiPeriodDiD hits inherit DifferenceInDifferences.__init__ and ride [M-045]). Redundant with vcov_type, same as its siblings. REMOVAL SCOPE (2026-10-18): the 4.0 removal also deletes LinearRegression's legacy-CR1 remap branch in linalg.py (the estimators.py twin named on [M-045]); _vcov_type_explicit STAYS." # ---- Phase 2b PR 1: the post-fit aggregation surface --------------------- # Reserved-id pool for the later 2b PRs (drafted with M-117): M-120 was @@ -1811,3 +1824,18 @@ rows: test_ref: tests/test_methodology_synthetic_control.py code_refs: [diff_diff/synthetic_control.py, docs/methodology/REGISTRY.md] notes: "Inert since the exact NNLS inner solve (#838 / #842): an exact solve has no stopping tolerance. Same warn-and-discard shim and ladder reasoning as [M-148]." + # ---- 4.0-cut framing amendment (2026-10-18) ------------------------------ + - id: M-150 + kind: param-value + group: renames-level + old: "diff_diff:WooldridgeDiDResults.to_dataframe[level]=event" + new: "diff_diff:WooldridgeDiDResults.to_dataframe[level]=event_study" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: shimmed + phase: 5 + warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py + code_refs: [diff_diff/wooldridge_results.py] + notes: "Locator-completeness amendment at the 4.0-cut framing: WooldridgeDiDResults.to_dataframe(level='event') has warned since 3.9 (the [M-044] rename PR), but only [M-086]'s notes recorded it - the matrix had no locator for this third surface of the 'event' -> 'event_study' value unification (aggregate(type=) is [M-086]; summary(aggregation=) is [M-087]). Twin of [M-136] (LPDiD's to_dataframe level value). test_ref: test_to_dataframe_level_rename in tests/test_v4_rename_semantic.py." diff --git a/docs/v4-design.md b/docs/v4-design.md index d964e401..fe9d6ceb 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -21,8 +21,11 @@ restates per-row lifecycle data and instead cites rows as `[M-###]`), All decisions below were locked with the maintainer on 2026-07-18 (six program decisions + eight naming-checkpoint decisions), with dated addenda: 2026-07-19 -(the diagnostic-family decision, section 3.5) and 2026-07-31 (the four -consolidation-scope decisions appended to the section 8 checkpoint list). +(the diagnostic-family decision, section 3.5), 2026-07-31 (the four +consolidation-scope decisions appended to the section 8 checkpoint list) and +2026-10-18 (the 4.0-cut framing at v3.13.0: the cut-window rule in sections 2 +and 11, the roster refresh in sections 3.1/3.5, the M-080 re-scope and the +M-008 decision in section 7, and the 4.0 cut sequence in section 9). Sections 3-8 are the target surface; section 9 maps it onto PRs. --- @@ -64,7 +67,16 @@ storage (section 5) and the two default policies -> 4.1 adds the front door -> **maint/3.8 branch rule:** 3.8.x patches are cut from main until the first Phase 2 PR merges; after that, main carries minor-worthy content, so further 3.8.x patches come from a `maint/3.8` branch cut at tag `v3.8.0` (fix lands on -main, cherry-pick back). +main, cherry-pick back). (Spent: no 3.8.x patch was ever needed.) + +**4.0 cut window (2026-10-18).** The 4.0 removals land INCREMENTALLY on main +over the PR sequence in section 9, under the cut window the ledger header +declares (`cut_window: "4.0"`, `cut_base: "3.13.0"`; mechanics in section 11). +`__version__` stays at 3.13.0 for the whole program and is bumped to 4.0.0 +only by the release PR, which closes the window. Because main carries removals +from the first cut PR onwards, 3.13.x patches come from a `maint/3.13` branch +cut at tag `v3.13.0` BEFORE the first cut PR merges (fix lands on main, +cherry-pick back) - the maint/3.8 rule re-instantiated one major later. **Warning categories:** all NEW shims emit `FutureWarning` (visible to end users by default). Pre-existing `DeprecationWarning` sites keep their category @@ -82,7 +94,11 @@ in the same diff; the PR's changelog fragment names the flipped row ids. ### 3.1 Estimator roster (fates) -Derived from `diff_diff.__all__` at v3.8.0. 24 estimator classes -> 21. +Derived from `diff_diff.__all__` at v3.8.0 (24 estimator classes) plus the +three estimators added in 3.10-3.12 (27). 27 -> 24 at 4.0, of which Bacon is +re-homed to the diagnostic family (section 3.5): 23 estimators + 1 diagnostic +entry point. (Amended 2026-10-18; the original "24 -> 21" counted the v3.8.0 +roster only.) | Class | Fate | |---|---| @@ -95,6 +111,7 @@ Derived from `diff_diff.__all__` at v3.8.0. 24 estimator classes -> 21. | ChangesInChanges | Keep + absorbs QDiD via `method=` [M-015] | | QDiD | Removed 4.0 [M-015] | | SyntheticDiD, SyntheticControl, TROP, ContinuousDiD, HeterogeneousAdoptionDiD, RegressionDiscontinuity, SpilloverDiD, ChaisemartinDHaultfoeuille | Keep | +| LWDiD (added 3.10), DMLDiD (added 3.11), DurationDiD (added 3.12) | Keep - born on the 4.0 vocabulary (no rename rows); LWDiD's results-contract gaps are PR-1 work (section 5) | | BaconDecomposition | Keep - moves into the diagnostic family (section 3.5) | ### 3.2 Final alias table @@ -193,8 +210,14 @@ identical - the consolidation is the source of truth from 4.0): BaconDecomposition (reclassified OUT of the README/API estimator lists), RDPlot (consolidated from the RD grouping), parallel-trends testing, the placebo suite, HonestDiD, PreTrendsPower, PowerAnalysis, -`twowayfeweights`, the HAD pretests, DiagnosticReport's results, and the -CJM 2020 density test (`RDDensityTest`, born into the family). Explicit +`twowayfeweights`, the HAD pretests, DiagnosticReport's results, the +CJM 2020 density test (`RDDensityTest`, born into the family), and - added +2026-10-18 for the members born after this section was written - the TWFE +weight diagnostics (`attgt_weights`, `decompose_twfe_weights`, +`plot_twfe_weights`; containers `ATTGTWeightsResult`, +`TWFEDecompositionResult`, 3.13) and DurationDiD's pretest container +(`DurationDiDPretestResults`, 3.12); `tests/test_diagnostic_marker.py` +already enrolls all four containers. Explicit NON-members despite current docs placement: Conley spatial-HAC (an embedded inference method on estimators, not a diagnostic object) and other design elements listed under the README section. (A narrower @@ -448,7 +471,15 @@ carry altered meaning). The quintet contract applies to ESTIMATOR results; diagnostic-family results (section 3.5) are exempt BY TYPE - they expose `summary()` / `to_dataframe()` but no inference row. As of v3.8.0 every ESTIMATOR result class already exposes the full quintet as properties -(verified 2026-07-18), so 3.9 needs no additive property work. At 4.0 the STORAGE flips [M-050]..[M-058]: the canonical names +(verified 2026-07-18), so 3.9 needs no additive property work. (2026-10-18: +`LWDiDResults` and `DurationDiDResults` were born with the canonical quintet +as NATIVE fields - no flip rows; `DMLDiDResults` inherits +`CallawaySantAnnaResults` storage and rides [M-050]. `LWDiDResults` deviates +from the uniform signatures - `summary()` lacks `alpha=None`, +`to_dataframe()` lacks the `level=` selector - and carries `pvalue`/`ci` +alias properties for `p_value`/`conf_int`; the signatures are added and the +aliases rowed for deprecation in the cut sequence's PR 1, section 9, on the +[M-148]/[M-149] same-PR-as-the-shim precedent.) At 4.0 the STORAGE flips [M-050]..[M-058]: the canonical names become the native dataclass fields; `overall_att` (and sibling `overall_*` inference fields, plus ContinuousDiD's deviant `overall_att_*` family [M-058]) become FutureWarning properties, removed 5.0. MultiPeriodDiD's @@ -707,6 +738,49 @@ domain vocabulary, not drift. Cross-sectional 2x2 estimators stay HC-robust unless `cluster=` is given. StackedDiD's hard-coded `cluster="unit"` default becomes an instance of the general policy rather than a special case. + + **Re-scoped 2026-10-18 (4.0-cut framing, user decision; measured roster + table in `docs/methodology/variance-conventions.md`).** The paragraph above + assumed static TwoWayFixedEffects runs HC-robust by default; it has + auto-clustered at unit since 2026-01 (`twfe.py`, REGISTRY TWFE section), so + the originally described flip moves no TWFE number. Measured by execution + at v3.13.0, every exported estimator falls into one category: + (a) `SpilloverDiD` - required `unit`, observation-level HC when `cluster=` + is omitted: the ONE estimator whose default SE moves at 4.0, mirroring + TWFE's carve-outs (explicit `cluster=` wins; Conley drops the auto-cluster; + it is never injected as a survey PSU); + (c1) static TWFE, SunAbraham, WooldridgeDiD, LPDiD - already cluster at + unit by a regression-level default; `cluster=False` yields the plain HC + sandwich; + (c2) ImputationDiD, TwoStageDiD - default `cluster_var = unit` INSIDE + unit-level influence-function machinery; no unclustered variance is + defined, so `cluster=False` is an accepted explicit no-op; + (c3) StackedDiD - clusters by construction and its validator accepts only + `'unit'`/`'unit_subexp'`; `cluster=False` is REJECTED with the existing + validator message, and the sentence above ("becomes an instance of the + general policy") is narrowed to the default-resolution half (REGISTRY + StackedDiD Note); + (b) CallawaySantAnna, EfficientDiD, DMLDiD, dCDH, the staggered DDD mode + (per-unit influence functions, no G/(G-1) factor) and LWDiD, HAD + (regression on a unit-level cross-section) - unit-level by construction; + `cluster=False` is an accepted explicit no-op; + (d) ContinuousDiD, DurationDiD, SyntheticDiD, SyntheticControl, TROP, CiC - + no `cluster` parameter at all; + (n/a) cross-sectional DiD, 2x2x2 DDD, RDD/RDPlot/RDDensityTest - HC-robust + or no clustering support; unchanged. + Categories b and d carry NO auto-cluster metadata by decision: + `cluster_name` stays `None` where the field exists and no field is added + where it does not (their SE formulas lack the G/(G-1) factor the explicit + `cluster="unit"` path applies, so labelling them `'unit'` would misstate + the formula). The 4.0 flip is therefore: the SpilloverDiD default; the + uniform `cluster=False` off-switch on categories a/b/c1/c2 (at v3.13.0 it + is accepted nowhere - LPDiD's `self.cluster or unit` treats it as + auto-cluster, most others look up a column named `False`, DMLDiD/StackedDiD + reject on type, dCDH raises on any non-None); and the metadata gaps (TWFE + and DiD report `n_clusters=None` on the analytical path; SunAbraham + auto-clusters but reports `cluster_name=None` and has no `n_clusters` + field). The [M-080] row's locator and `code_refs` were re-pointed + accordingly. - **df_convention default flip** [M-004]..[M-006] + [M-128]..[M-131]: `"residual"` -> `"cluster"` (G-1) at 4.0 - the locked #663 direction. PR C ([M-127], 3.9) resolved the "evaluate extending the knob" item EARLY: @@ -717,6 +791,12 @@ domain vocabulary, not drift. p-value/CI; the flip PR updates `TestDfConvention` / `test_moderate_t_pins_residual_df_convention` / the per-estimator knob suites' expectations and adds the migration-guide entry. +- **Fast-path default** [M-008]: decided 2026-10-18 (4.0-cut framing, user + decision) - evaluated, KEPT OFF. No opt-in telemetry was ever collected in + the 3.9 -> 3.13 gap and the golden recapture never ran, so there is no + evidence base for a flip; "evaluated, kept off" is the terminal state the + row always allowed. The TODO.md fastpath row stays as a 4.x re-evaluation + (a later flip is a major-version change on its own schedule). - Constructor hygiene rides Phase 2: ContinuousDiD's `covariates` moves from `__init__` to `fit()` [M-084] (the one estimator with a data column in the constructor), and the shared `BaseEstimator` mixin replaces the 25 @@ -794,6 +874,11 @@ missed.** guard (`tests/test_naming_guard.py`) and removal PRs consult the family's `code_refs` UNION, and the pre-terminal repo-wide grep above remains the per-row safety net (decided 2026-08-01 over per-row duplication). + Corollary (2026-10-18): the shim helper module `diff_diff/_deprecation.py` + SURVIVES 4.0 - the 4.0->5.0 FutureWarning property shims of [M-016] and + [M-050]..[M-058] are its consumers through the 5.0 window - so the 4.0 + removal PRs delete the shims that use it, never the module (recorded in + [M-050]'s `code_refs`). **Missed-rename amendments (2026-08-02, the 2(c)-ii PR-B sweep):** [M-136] (LPDiD `to_dataframe(level="event")` - the drifted value spelling every @@ -851,7 +936,7 @@ above; anything only one PR cares about stays in that PR's plan.** | 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] [M-139] (M-020's shim already shipped; M-139 is the HAD workflow twin, a pre-cut amendment); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + the 2(c)-ii missed-rename amendments [M-136..M-138] (LPDiD `level` value; the two post-dummy diagnostics params) + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] | | 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] + the fit `time`->`post` rename [M-082] (gates: section 4.1's equivalence/divergence/pooled-parity test triple) (shipped: tests/test_v4_merge_mpd.py; consumer ports incl. HonestDiD/PreTrendsPower calendar routes); (b) TripleDifference facade [M-013] + the SDDD alias [M-064] (shipped: tests/test_v4_merge_ddd.py; the engine relocation into the private shared mixin, the keyword-only staggered fit params, and the pscore_trim tightening [M-142]. The fit-time aggregate=/balance_e= carve-out rows are scheduled REMOVALS and so are cited in the phase-5 cell, not here - their only lifecycle version is removed_in 4.0); (c) CiC method= [M-015] + its results-field mirror [M-143] (shipped: tests/test_v4_merge_cic.py; method= is keyword-only and lowercase-only, the QDiD CLASS is deprecated while the METHOD is not, and ChangesInChangesResults.estimator -> .method carries a dual-key to_dict() window through 3.9) | | 4: release + soak | 3.9 cut | Migration guide written (skeleton: section 10); maintainer cuts 3.9; maint/3.8 rule active | -| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-139, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120, M-140, M-141, M-143] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; the family-wide anticipation validation [M-144], the family-wide pscore_trim validation [M-145], and the family-wide summary-alpha guard [M-146] (staggered family at the initial cut; extended to the non-staggered summaries post-cut) (behavior tightenings landing at 4.0 - shipped post-3.9-cut, terminal `done`, no removal/deprecation fields); the additive Wooldridge comparison-support policy [M-147] (`unsupported_period_action="drop"` preserves filtering, `"error"` refuses before period removal; same post-cut lifecycle); the inert SyntheticControl inner-budget pair [M-148] [M-149] (warn-and-discard shims at 4.0 once the donor weights moved to an exact NNLS, removal at 5.0); docs/llms.txt/README refresh | +| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-139, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120, M-140, M-141, M-143] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] [M-150] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; the family-wide anticipation validation [M-144], the family-wide pscore_trim validation [M-145], and the family-wide summary-alpha guard [M-146] (staggered family at the initial cut; extended to the non-staggered summaries post-cut) (behavior tightenings landing at 4.0 - shipped post-3.9-cut, terminal `done`, no removal/deprecation fields); the additive Wooldridge comparison-support policy [M-147] (`unsupported_period_action="drop"` preserves filtering, `"error"` refuses before period removal; same post-cut lifecycle); the inert SyntheticControl inner-budget pair [M-148] [M-149] (warn-and-discard shims at 4.0 once the donor weights moved to an exact NNLS, removal at 5.0); docs/llms.txt/README refresh | | 6: front door | 4.1 | `event_study(data, outcome, unit, time, first_treat, estimator=...)` comparison entry point over the staggered family (sketch only; specified in its own plan) | Citation semantic for the table: a cell may cite a row whose current `phase` @@ -907,6 +992,54 @@ re-enumerate the cells' M-id lists: 7. Phase 3 merges (a)/(b)/(c) per the phase-3 cell. 8. Phase 4: migration guide, the 3.9-cut checklist below, cut. +**4.0 cut sequence (2026-10-18).** The single canonical statement of the +cut's PR order; order and rationale only - the M-id rosters live in the +phase-5 cell above and the lifecycle facts in the ledger. Every PR lands on +main under the section 2 cut window with `__version__` unchanged; each flips +its rows terminal and names them in its changelog fragment. + +0. Spec/ledger refresh (the PR that wrote this subsection): the cut-window + rule, the roster/M-080/M-008 amendments, the [M-150] locator, the id + snapshot repair, and the `maint/3.13` branch cut. +1. New-estimator conformance (additive): `LWDiDResults.summary(alpha=None)` / + `to_dataframe(level=)`, rows for its `pvalue`/`ci` aliases (lifecycle in + the rows, on the [M-148]/[M-149] same-PR precedent), DurationDiD + `aggregate("simple")`, `RDDensityTestResult.to_dict()`. +2. Mechanical shim removals: the rename rows, the wrapper functions, the + alias diet, the SyntheticDiD params, the legacy-CR1 remap branches named on + [M-045]/[M-115]; `_deprecation.py` stays (section 8 rule 11 corollary). +3. Fit-time `aggregate=`/`balance_e=` removals, plus the runtime messages that + still steer users to the deprecated route (readers recorded in the rows' + `code_refs`) and the benchmark/guide sweep. +4. The three merges: the MultiPeriodDiD class, `MultiPeriodDiDResults`, + `PeriodEffect` and `EventStudy`; the QDiD class and `QDiDResults`; the + `StaggeredTripleDifference` CLASS and `SDDD` - but NOT + `StaggeredTripleDiffResults` [M-014], which the surviving facade's + staggered mode still constructs and which dies in PR 6 (the DDD container unification). Rides along: + [M-007], the [M-016] property view on `EventStudyResults`, the [M-083] / + [M-085] raises, the `pyproject.toml` `filterwarnings` entries, the + tutorials built on MultiPeriodDiD (01/02/04/05/07/23, rebuilt on + `TWFE(event_study=True)` - user decision), the MultiPeriodDiD benchmark arm + (relabelled `spec="pooled"`), the power registry, and the + `doc-deps.yaml` STD group/key. +5. Storage flip [M-050]..[M-058] + pickle migration + the `.att` sweep over + docs/notebooks/guides/benchmarks (incl. a disposition for the un-rowed + `CSBootstrapResults.overall_att_se`). +6. DDD container unification [M-014] [M-140] [M-141] (user decision: lands at + 4.0, not re-scheduled), born with canonical fields - after 5, so the + unified container never carries `overall_*` storage. +7. Inference defaults: the df_convention flips + [M-080] as re-scoped + + the uniform `cluster=False` off-switch. +8. [M-093] sentinel retirement + event-study schema enforcement. +9. [M-090] docs reorganization: Bacon to the diagnostics family, catalogue + counts, JOSS prose, REGISTRY archive framing for the removed classes. +10. Release: bump to 4.0.0 (closes the cut window), changelog compile, + migration-guide retitle, due gate green. + +Ordering constraints: 4 before 5 and 6; 5 before 6 (the unified DDD +container is born canonical); 7 and 8 independent; 9 after 4/6; 10 last. +Waves 2 and 3 are independent of each other and of 4. + **3.9-cut checklist (un-rowed obligations).** `test_due_rows_are_terminal` gates everything that HAS a row; the following Phase 2 obligations are real but not expressible as ledger rows, so the 3.9 release PR asserts them by hand: @@ -968,7 +1101,11 @@ whose version reaches a row's scheduled version while its status has not flipped (rows must flip or be explicitly re-scheduled in the release PR). The remaining manual item: re-run the repo-wide deprecation grep (DeprecationWarning/FutureWarning/"will be removed"/"removed in v"/"next -major"/"flip") to catch anything born outside the matrix. +major"/"flip") to catch anything born outside the matrix. RUN 2026-10-18 at +v3.13.0 (4.0-cut framing): every FutureWarning/DeprecationWarning site (25 +direct, 52 via the `_deprecation.py` helpers, 12 Sphinx `.. deprecated::` +directives, the module `__getattr__`) resolves to a ledger row - nothing was +born outside the matrix in 3.10-3.13. Re-run once more in the release PR. ## 10. Migration-guide skeleton (`docs/migration-4.0.md`, written in Phase 4) @@ -1113,7 +1250,9 @@ forever - a removed symbol resurrecting is a test failure. class/function rows and alias rows also assert `__all__` membership consistent with their status (stale `import *` entries fail). The shipped row ids are a - committed snapshot in the enforcement test (129 as of the ETWFE + committed snapshot in the enforcement test (132 as of the 4.0-cut framing - + M-148/M-149 were shipped by #838 without extending the snapshot, so the + framing PR added them together with M-150; 129 as of the ETWFE unsupported-period policy; previously 126 as of the family-wide anticipation validation row: Phase 1 + the diagnostic-family amendment + @@ -1122,11 +1261,28 @@ forever - a removed symbol resurrecting is a test failure. reference-period pair M-123/M-124 + M-125 + M-126 + M-127..M-131 + the alias-diet family M-132..M-135 + the 2(c)-ii amendments M-136..M-138 + M-139 + the DDD-merge rows M-140..M-142 + the CiC - results-field mirror M-143 + the anticipation policy row M-144 + the DML review-follow-ups pair M-145/M-146 + the unsupported-period policy M-147 + the SyntheticControl inner-budget pair M-148/M-149; + results-field mirror M-143 + the anticipation policy row M-144 + the DML review-follow-ups pair M-145/M-146 + the unsupported-period policy M-147 + the SyntheticControl inner-budget pair M-148/M-149 + the Wooldridge to_dataframe level twin M-150; the snapshot extends by a new id range in the same diff that appends rows): ids are never deleted or reused, and the test fails if any snapshot id disappears. +**Cut window (2026-10-18).** The ledger header may declare +`cut_window: ".0"` and `cut_base: ""` together (a +half-declared pair is a format error). While declared: (i) a row whose +SCHEDULED version (`removed_in` for symbol/param-value/alias kinds, the flip +version for default-flip/warning-retirement/behavior, `decision_due` for +env-default) equals `cut_window` MAY be terminal before `__version__` reaches +it - the early-removal guard is relaxed for exactly that version, so the +major's removals land over many PRs; (ii) while any such early-terminal row +exists, `__version__` must equal `cut_base` or have reached `cut_window` - +any other bump (a minor or a patch cut from main) fails the OFF-BASE guard, +because it would ship the removals in a non-major release; (iii) due +enforcement is unchanged - at `cut_window` every scheduled row must be +terminal, exactly as before. The release PR bumps `__version__` to +`cut_window`, which closes the window; the keys stay as a historical record +(`test_ledger_header_declares_cut_window` asserts `cut_base == __version__` +only while the window is open). + **Cross-row migration rule.** Removing a symbol requires migrating, in the same diff, every other row whose locators or `code_refs` reference it (e.g. [M-016]'s locator moves to the successor container when [M-011] flips). The diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index 25dc4d1d..8716ce53 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -18,8 +18,10 @@ that is terminal (``done``/``removed`` - exempt both directions) or has P's ship-version (phases 2/3/4 -> "3.9", 5 -> "4.0", 6 -> "4.1") among its ``introduced_in``/``deprecated_in``/``removed_in`` - or ``decision_due`` for - ``env-default`` rows (M-008, scheduled solely via ``decision_due: "4.0"``, - is the named positive fixture). + ``env-default`` rows (the ``decision_due`` channel is pinned on synthetic + rows in ``test_direction2_predicate_on_synthetic_rows``; M-008, the real + env-default row, went ``done`` at the 4.0-cut framing and is now the named + TERMINAL-EXEMPT fixture). Citation grammar is BRACKETED-ONLY, three forms: single tokens (``[M-122]``), compound single brackets with ``..`` ranges, comma elements and trailing prose (``[M-030, M-032..M-047 old names]``), and @@ -1261,20 +1263,20 @@ def test_phase_table_direction2_every_citation_has_lifecycle_at_ship_version(): assert not bad, f"section-9 citations failing direction 2: {bad}" -def test_named_fixtures_m008_passes_and_m031_prose_is_not_a_citation(): +def test_named_fixtures_m008_terminal_exempt_and_m031_prose_is_not_a_citation(): cells = extract_phase_cells(SPEC.read_text()) phase5_raw = cells[5][1] citations = expand_citations(phase5_raw) - # Positive fixture: M-008 is scheduled SOLELY via decision_due. + # Terminal-exempt fixture: M-008 (env-default, scheduled solely via decision_due) + # went `done` at the 4.0-cut framing; it stays cited in the phase-5 cell and the + # direction-2 predicate accepts it regardless of kind because terminal rows are exempt + # both directions. The decision_due CHANNEL itself is pinned on synthetic non-terminal + # rows in test_direction2_predicate_on_synthetic_rows. m008 = _ROW_INDEX["M-008"] assert m008.get("kind") == "env-default" + assert m008.get("status") == "done" assert "M-008" in citations assert direction2_ok(m008, "4.0") - stripped = dict(m008) - stripped["kind"] = "param" - assert not direction2_ok( - stripped, "4.0" - ), "M-008 must pass ONLY through the env-default decision_due channel" # Negative fixture: bare prose id is not a citation. assert "M-031" in phase5_raw, "phase-5 cell should still carve out M-031 in prose" assert "M-031" not in citations, "bare prose M-031 must not be tokenized as a citation" diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py index 15b3e1a4..aac57233 100644 --- a/tests/test_v4_matrix.py +++ b/tests/test_v4_matrix.py @@ -20,6 +20,12 @@ version bump that reaches a row's scheduled version (``removed_in``, flip version, ``decision_due``, or ``introduced_in``) while the row has not flipped (spec section 9) - due rows must flip or be re-scheduled in the release PR. +The ledger header may declare an OPEN CUT WINDOW (``cut_window`` + ``cut_base``, +spec section 11): rows scheduled at the window version may go terminal EARLY +(the removals land incrementally while ``__version__`` stays at ``cut_base``), +any other version bump while early-terminal rows exist is refused, and due +enforcement itself is unchanged - at the window version every scheduled row +must be terminal. Parsing is a purpose-built line scanner (restricted-YAML format contract in the ledger header) because the project ships no PyYAML - same precedent as @@ -132,11 +138,13 @@ # + the family-wide anticipation validation row (M-144) = 126; + the DML # review-follow-ups pair (M-145 family-wide pscore_trim validation, M-146 # family-wide summary-alpha guard) = 128; + the ETWFE unsupported-period -# policy (M-147) = 129. +# policy (M-147) = 129; + the SyntheticControl inner-budget pair (M-148/M-149, +# shipped by #838 without extending this floor) = 131; + the Wooldridge +# to_dataframe level twin (M-150, 4.0-cut framing) = 132. # Ids are never reused and terminal rows are never deleted, so the ledger # only grows - raise the floor when rows are added; a lower parse count # means scanner/format drift or an illegal row deletion. -ROW_COUNT_FLOOR = 129 +ROW_COUNT_FLOOR = 132 # Committed snapshot of the shipped id set ("ids are never deleted or reused" # contract - a delete-one-add-one edit keeps the count above the floor but trips @@ -193,6 +201,8 @@ (144, 144), (145, 146), (147, 147), + (148, 149), + (150, 150), ] EXPECTED_INITIAL_IDS = frozenset( f"M-{n:03d}" for lo, hi in _INITIAL_ID_RANGES for n in range(lo, hi + 1) @@ -211,6 +221,52 @@ def _parse_scalar(raw): return raw +_HEADER_RE = re.compile(r"^([a-z_]+):\s*(.*?)\s*$") +#: Header keys the ledger may declare above ``rows:`` (spec section 11, "Cut window"). +KNOWN_HEADER_KEYS = {"cut_window", "cut_base"} + + +def parse_header(text): + """Parse the ledger's pre-``rows:`` header scalars; returns (header, errors). + + Today the header carries the cut-window pair ``cut_window``/``cut_base`` (spec section 11): + both must be present together ("half-declared" is an error), each must match the ledger + version grammar, and unknown keys are errors - the header is part of the format contract. + An absent header is legal and returns ``({}, [])``. + """ + header, errors = {}, [] + for lineno, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped == "rows:": + break + m = _HEADER_RE.match(line) + if not m: + errors.append(f"line {lineno}: unparseable header line: {stripped!r}") + continue + key, raw = m.group(1), m.group(2) + if key not in KNOWN_HEADER_KEYS: + errors.append(f"line {lineno}: unknown header key '{key}'") + continue + if key in header: + errors.append(f"line {lineno}: duplicate header key '{key}'") + continue + value = _parse_scalar(raw) + if value is None or not _VERSION_RE.match(str(value)): + errors.append(f"line {lineno}: header '{key}' must be a version, got {raw!r}") + continue + header[key] = value + declared = {k for k in KNOWN_HEADER_KEYS if k in header} + if declared and declared != KNOWN_HEADER_KEYS: + missing = sorted(KNOWN_HEADER_KEYS - declared) + errors.append( + f"half-declared cut window: {sorted(declared)} present but {missing} missing - " + "declare both or neither" + ) + return header, errors + + def parse_matrix(text): """Parse the restricted-YAML ledger; returns (rows, errors). @@ -440,6 +496,12 @@ def _signature_default(locator, rid): # --------------------------------------------------------------------------- ROWS, _PARSE_ERRORS = parse_matrix(MATRIX.read_text()) _ROW_IDS = [r.get("id", "?") for r in ROWS] +_HEADER, _HEADER_ERRORS = parse_header(MATRIX.read_text()) +#: The open cut window (spec section 11). Set ONLY from a header that parsed with zero errors +#: - a malformed or half-declared header leaves both None and fails +#: ``test_ledger_header_is_well_formed`` instead of reaching the gate. +CUT_WINDOW = _HEADER.get("cut_window") if not _HEADER_ERRORS else None +CUT_BASE = _HEADER.get("cut_base") if not _HEADER_ERRORS else None def test_matrix_parses_cleanly(): @@ -498,7 +560,19 @@ def _version_tuple(version): return tuple(parts + [0] * (3 - len(parts)))[:3] -def collect_due_problems(rows, current): +def _scheduled_version(row): + """The version a row's TERMINAL transition is scheduled for (spec section 11 release gate): + ``removed_in`` for symbol/param-value/alias kinds, the flip version (``deprecated_in``) for + default-flip/warning-retirement/behavior rows, ``decision_due`` for env-default rows.""" + kind = row["kind"] + if kind in ("default-flip", "warning-retirement", "behavior"): + return row.get("deprecated_in") + if kind == "env-default": + return row.get("decision_due") + return row.get("removed_in") + + +def collect_due_problems(rows, current, cut_window=None, cut_base=None): """Pure due-row sweep over parsed rows at version tuple ``current`` (unit-testable). Rules (spec section 11 release gate): @@ -514,22 +588,39 @@ def collect_due_problems(rows, current): - EARLY-REMOVAL GUARD: a row must NOT be terminal while its scheduled version (``removed_in``, or the flip version for non-symbol kinds) is still in the future - the shim window is a promise to users, not a suggestion. + - CUT WINDOW (spec section 11): when the ledger header declares ``cut_window``/``cut_base``, + rows scheduled at exactly ``cut_window`` are exempt from the early guard (the major's + removals land incrementally while ``__version__`` stays at ``cut_base``), and the OFF-BASE + guard refuses any ``current`` that is neither ``cut_base`` nor ``>= cut_window`` while such + early-terminal rows exist (no minor/patch release may ship the removals). Due enforcement + below is NOT relaxed by the window. """ + if (cut_window is None) != (cut_base is None): + raise ValueError( + "cut_window and cut_base must be declared together (half-declared cut window: " + f"cut_window={cut_window!r}, cut_base={cut_base!r})" + ) + window = _version_tuple(cut_window) if cut_window is not None else None + base = _version_tuple(cut_base) if cut_base is not None else None problems = [] for row in rows: rid, kind, status = row["id"], row["kind"], row["status"] removed_in = row.get("removed_in") if status in ("removed", "done"): - early_version = removed_in - if kind in ("default-flip", "warning-retirement", "behavior"): - early_version = row.get("deprecated_in") - elif kind == "env-default": - early_version = row.get("decision_due") + early_version = _scheduled_version(row) if early_version and current < _version_tuple(early_version): - problems.append( - f"{rid}: status '{status}' but its scheduled version {early_version} is " - f"still in the future - early removal/flip breaks the shim window promise" - ) + if window is not None and _version_tuple(early_version) == window: + if current != base: + problems.append( + f"{rid}: terminal under open cut window {cut_window} but " + f"__version__ moved off cut_base {cut_base} - release the window " + f"at {cut_window} or revert the bump" + ) + else: + problems.append( + f"{rid}: status '{status}' but its scheduled version {early_version} is " + f"still in the future - early removal/flip breaks the shim window promise" + ) if removed_in and current >= _version_tuple(removed_in): if kind in LIFECYCLE_KINDS and status != "removed": problems.append(f"{rid}: removed_in {removed_in} is due but status is '{status}'") @@ -580,7 +671,9 @@ def test_due_rows_are_terminal(): or was explicitly re-scheduled in the release PR.""" import diff_diff - problems = collect_due_problems(ROWS, _version_tuple(diff_diff.__version__)) + problems = collect_due_problems( + ROWS, _version_tuple(diff_diff.__version__), cut_window=CUT_WINDOW, cut_base=CUT_BASE + ) assert not problems, ( f"rows due at version {diff_diff.__version__} have not flipped " "(flip them or re-schedule in the release PR):\n" + "\n".join(problems) @@ -591,14 +684,16 @@ def test_initial_ids_never_deleted(): """The shipped id set is immutable: ids are never deleted or reused (spec section 11). ROW_COUNT_FLOOR alone would let a delete-one-add-one edit pass; this snapshot cannot. - Extends as rows ship (129 as of the ETWFE unsupported-period policy: + Extends as rows ship (132 as of the 4.0-cut framing: Phase 1 + diagnostic-family + M-092/M-093 + M-094..M-096 + the M-097..M-115 public-function completeness sweep + M-117..M-120/M-122 + M-123/M-124 + M-125 + M-126 + M-127..M-131 + M-132..M-135 + - M-136..M-138 + M-139 + M-140..M-142 + M-143 + M-144 + M-145/M-146 + M-147).""" + M-136..M-138 + M-139 + M-140..M-142 + M-143 + M-144 + M-145/M-146 + M-147 + + M-148/M-149 (shipped by #838 without extending the snapshot; added at the + 4.0-cut framing) + M-150).""" missing = sorted(EXPECTED_INITIAL_IDS - set(_ROW_IDS)) assert not missing, f"ledger rows deleted (ids are permanent): {missing}" - assert len(EXPECTED_INITIAL_IDS) == 129 + assert len(EXPECTED_INITIAL_IDS) == 132 def test_version_tuple_pads_to_three_components(): @@ -751,6 +846,172 @@ def test_due_gate_catches_overdue_rows(row_overrides, current, expected_substrin assert not collect_due_problems([row_overrides], clean_at) +_WINDOW_ROW = { + "id": "M-910", + "kind": "param", + "old": "diff_diff:SyntheticDiD[lambda_reg]", + "new": None, + "deprecated_in": "3.9", + "removed_in": "4.0", + "status": "removed", + "phase": 5, +} + + +@pytest.mark.parametrize( + "row, current, cut_window, cut_base, expect_clean, expected_substring", + [ + (dict(_WINDOW_ROW), (3, 13, 0), "4.0", "3.13.0", True, None), + ( + { + "id": "M-911", + "kind": "behavior", + "old": "diff_diff:SpilloverDiD[cluster]", + "new": None, + "deprecated_in": "4.0", + "removed_in": None, + "status": "done", + "phase": 5, + }, + (3, 13, 0), + "4.0", + "3.13.0", + True, + None, + ), + ( + { + "id": "M-912", + "kind": "env-default", + "old": "diff_diff.linalg:_resolve_solve_ols_fastpath", + "new": None, + "deprecated_in": None, + "removed_in": None, + "decision_due": "4.0", + "decided_default": "off", + "status": "done", + "phase": 5, + }, + (3, 13, 0), + "4.0", + "3.13.0", + True, + None, + ), + ( + dict(_WINDOW_ROW, removed_in="5.0"), + (3, 13, 0), + "4.0", + "3.13.0", + False, + "still in the future", + ), + (dict(_WINDOW_ROW), (3, 13, 0), None, None, False, "still in the future"), + (dict(_WINDOW_ROW), (3, 14, 0), "4.0", "3.13.0", False, "moved off cut_base"), + (dict(_WINDOW_ROW), (3, 13, 1), "4.0", "3.13.0", False, "moved off cut_base"), + (dict(_WINDOW_ROW), (4, 0, 0), "4.0", "3.13.0", True, None), + ( + dict(_WINDOW_ROW, status="shimmed"), + (4, 0, 0), + "4.0", + "3.13.0", + False, + "removed_in 4.0 is due", + ), + ], + ids=[ + "early-removal-inside-open-window", + "early-flip-behavior-inside-window", + "early-env-default-inside-window", + "early-removal-outside-window", + "no-window-still-guards", + "minor-bump-off-base-trips", + "patch-bump-off-base-trips", + "release-closes-window", + "window-does-not-hide-overdue", + ], +) +def test_cut_window_semantics(row, current, cut_window, cut_base, expect_clean, expected_substring): + """The cut window relaxes ONLY the early guard, only for its own version, only while + ``current`` sits at ``cut_base`` (or has reached the window); due enforcement is untouched.""" + problems = collect_due_problems([row], current, cut_window=cut_window, cut_base=cut_base) + if expect_clean: + assert not problems, problems + else: + assert any( + expected_substring in p for p in problems + ), f"expected a problem containing {expected_substring!r}, got: {problems}" + + +def test_collect_due_problems_rejects_half_declared_window(): + with pytest.raises(ValueError, match="half-declared cut window"): + collect_due_problems([dict(_WINDOW_ROW)], (3, 13, 0), cut_window="4.0") + + +@pytest.mark.parametrize( + "text, expected_header, n_errors", + [ + ("rows:\n - id: M-001\n", {}, 0), + ( + 'cut_window: "4.0"\ncut_base: "3.13.0"\nrows:\n', + {"cut_window": "4.0", "cut_base": "3.13.0"}, + 0, + ), + ( + '# comment\ncut_window: "4.0"\n\ncut_base: "3.13.0"\nrows:\n', + {"cut_window": "4.0", "cut_base": "3.13.0"}, + 0, + ), + ('cut_window: "4"\ncut_base: "3.13.0"\nrows:\n', {"cut_base": "3.13.0"}, 2), + ('cut_window: "4.0"\nrows:\n', {"cut_window": "4.0"}, 1), + ( + 'cut_window: "4.0"\ncut_base: "3.13.0"\nfoo: "1.0"\nrows:\n', + {"cut_window": "4.0", "cut_base": "3.13.0"}, + 1, + ), + ], + ids=[ + "no-header", + "both-keys", + "comments-and-blanks-ignored", + "malformed-version-leaves-half-declared", + "half-declared", + "unknown-key", + ], +) +def test_parse_header_cases(text, expected_header, n_errors): + """The header parser is exercised on TEXT (not decoded arguments): absent header, the + cut-window pair, malformed versions, a half-declared pair and unknown keys.""" + header, errors = parse_header(text) + assert header == expected_header + assert len(errors) == n_errors, errors + + +def test_ledger_header_is_well_formed(): + """The real ledger's header parses with zero errors (a malformed/half-declared header + leaves CUT_WINDOW/CUT_BASE None and must fail HERE, never reach the gate silently).""" + assert not _HEADER_ERRORS, _HEADER_ERRORS + + +def test_ledger_header_declares_cut_window(): + """The 4.0 cut window is declared (spec section 11): it names a version some row is + scheduled at, and while the window is OPEN (``__version__`` below it) ``cut_base`` must equal + the shipped ``__version__`` - the off-base guard's reference point. Once ``__version__`` + reaches the window the keys are a historical record (rows scheduled past the window, e.g. the + 5.0 field-alias removals, legitimately stay non-terminal, so nothing is asserted then).""" + import diff_diff + + assert CUT_WINDOW == "4.0" + assert CUT_BASE is not None + scheduled = {_version_tuple(v) for v in (_scheduled_version(r) for r in ROWS) if v is not None} + assert _version_tuple(CUT_WINDOW) in scheduled, "cut_window names no scheduled version" + if _version_tuple(diff_diff.__version__) < _version_tuple(CUT_WINDOW): + assert _version_tuple(CUT_BASE) == _version_tuple(diff_diff.__version__), ( + f"cut window {CUT_WINDOW} is open but cut_base {CUT_BASE} != " + f"__version__ {diff_diff.__version__}" + ) + + @pytest.mark.parametrize("row", ROWS, ids=_ROW_IDS) def test_row_matches_reality(row, monkeypatch): """The core enforcement: each row's status must match reality at HEAD (spec section 11).""" @@ -1062,7 +1323,7 @@ def _changes_at_4_0(row): Keyed on the two LIFECYCLE version fields only - a symbol removed at 4.0, or one whose deprecation warning starts firing at 4.0 (the ``field-flip`` family, removed - at 5.0). 108 of the 129 rows qualify. + at 5.0). 111 of the 132 rows qualify. The 21 that do not, and why (this enumeration is the contract - a reader of the guide must be able to trust that nothing 4.0-relevant was dropped): @@ -1080,12 +1341,12 @@ def _changes_at_4_0(row): - ``M-031``, ``M-082``: ``deprecated_in: 3.9`` with ``removed_in: null``, because the NAME ``time`` survives with a new meaning. Their 4.0 enforcement rows (``M-085`` and ``M-083`` respectively) DO qualify and carry the raise. - - ``M-008``: ``decision_due: 4.0`` but ``status: evaluate`` with no - ``decided_default``. ``decision_due`` is deliberately NOT part of this predicate: - the outcome is unknown and "evaluated, kept off" is a terminal state (spec - section 11), so it is documented as a pending go/no-go rather than as a change - the reader must act on. ``collect_due_problems`` does gate on ``decision_due`` - - that is the release sweep, a different question from "what must a user change". + - ``M-008``: ``decision_due: 4.0``, decided at the 4.0-cut framing (``status: done``, + ``decided_default: off`` - evaluated, kept off). ``decision_due`` is deliberately NOT + part of this predicate: a kept-off decision changes nothing a user must act on, so + it is documented in the guide's prose rather than as an appendix row. + ``collect_due_problems`` does gate on ``decision_due`` - that is the release sweep, + a different question from "what must a user change". """ return _at_4_0(row.get("removed_in")) or _at_4_0(row.get("deprecated_in"))