From 6e5dd83490a1650b478deaf7a4702d2588aa9fb5 Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 18 Jul 2026 20:26:35 -0400 Subject: [PATCH] docs(v4): 4.0 design spec + CI-enforced deprecation ledger (Phase 1) Normative design for the 4.0 program (docs/v4-design.md): three estimator merges (TWFE absorbs MultiPeriodDiD with a unit-FE event-study default and spec="pooled" compatibility, TripleDifference facade over the DDD pair, CiC absorbs QDiD via method=), post-fit results.aggregate() with the unified {simple, event_study, group, calendar} vocabulary, canonical results quintet storage flip, library-wide param/column contract rules (post vs time, first_treat, covariates, no _col suffixes), one inference surface, the final alias table, and the 3.9-shim-then-4.0-enforce release sequencing with the maint/3.8 patch-line rule. Every deprecation lifecycle is a row in docs/v4-deprecations.yaml (71 rows: the six locked design decisions expanded per-surface plus all five pre-existing 4.0 obligations found by repo sweep, including the df_convention default flip that carries no in-code warning; Wooldridge's existing aggregate(type=)/summary(aggregation=) surfaces are covered by dedicated value-migration rows). tests/test_v4_matrix.py asserts every row's status against reality at HEAD: planned rows tripwire on new surface appearing unflipped, shimmed/removed rows pin both sides plus dedicated-test existence, param-value rows carry the full lifecycle with test_ref-backed value enforcement, alias rows assert identity and __all__ membership, default-flip rows pin signature defaults, and the release gate is two-sided (due rows must flip; early removals before the scheduled version fail). Wired into docs-tests.yml so docs-only ledger/spec edits cannot bypass enforcement. REGISTRY.md MultiPeriodDiD gains a labeled Note reconciling the unit-FE parity target with the 3.x pooled default and the scheduled 4.0 migration. Docs + test only; no public API or numerical behavior change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017eVKbBqzenDKa5idwtexQa --- .claude/memory.md | 46 +- .github/workflows/docs-tests.yml | 9 + CHANGELOG.md | 9 + CLAUDE.md | 1 + ROADMAP.md | 4 + TODO.md | 4 +- docs/conf.py | 1 + docs/methodology/REGISTRY.md | 16 + docs/v4-deprecations.yaml | 942 +++++++++++++++++++++++++++++++ docs/v4-design.md | 546 ++++++++++++++++++ tests/test_v4_matrix.py | 924 ++++++++++++++++++++++++++++++ 11 files changed, 2484 insertions(+), 18 deletions(-) create mode 100644 docs/v4-deprecations.yaml create mode 100644 docs/v4-design.md create mode 100644 tests/test_v4_matrix.py diff --git a/.claude/memory.md b/.claude/memory.md index 6acd89aa4..1d32eda57 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -81,22 +81,36 @@ results = estimator.fit(df, outcome='y', treatment='treated', ...) print(results.summary()) ``` -### Results Objects -All results have: -- `.att` - Point estimate -- `.se` - Standard error -- `.pvalue` - Two-sided p-value -- `.ci` - Tuple of (lower, upper) confidence interval -- `.summary()` - Print formatted summary -- `.to_dict()` - Export to dictionary -- `.to_dataframe()` - Export to DataFrame - -### Column Naming -- `unit` or `unit_id` - Unit identifier -- `time` or `time_id` - Time period identifier -- `treated` - Binary treatment indicator (0/1) -- `post` - Binary post-period indicator (0/1) -- `cohort` or `treatment_time` - First treatment period for staggered designs +### Results Objects — CURRENT (3.x) +The canonical inference quintet is `att` / `se` / `t_stat` / `p_value` / +`conf_int` (never `.pvalue` / `.ci`). Every results class exposes all five, +but native STORAGE varies in 3.x (`overall_att` on the staggered family, +`avg_att` on MultiPeriodDiD, with canonical names as properties). `summary()` +everywhere; `to_dict()` / `to_dataframe()` on most classes. See +`.claude/../docs/methodology/REGISTRY.md` per estimator. + +### Results Objects — 4.0 TARGET (do not write against this pre-4.0) +Canonical quintet becomes the native fields on every class; `overall_att` +family becomes FutureWarning properties (removed 5.0); one unified +event-study representation; aggregation via post-fit +`results.aggregate(type=)`. Normative spec: `docs/v4-design.md`; per-surface +lifecycle: `docs/v4-deprecations.yaml` (CI-enforced by +`tests/test_v4_matrix.py`). + +### Column Naming — CURRENT (3.x) +- `unit` unit id (`unit_col` on HAD; `group` on dCDH — both slated for 4.0) +- `time` calendar period, EXCEPT DifferenceInDifferences / TripleDifference / + static TwoWayFixedEffects, where `time` is the 0/1 post dummy (TWFE warns + on >2 unique values; 4.0 renames these to `post`, and TWFE's `time` + becomes the event-study calendar column) +- `treatment` 0/1 treated-group indicator; `first_treat` cohort column + (`cohort` on WooldridgeDiD — slated for 4.0) +- `covariates` covariate list (`controls` on dCDH — slated for 4.0) + +### Column Naming — 4.0 TARGET +`outcome` / `unit` / `time` (calendar) / `post` (0/1) / `treatment` (0/1) / +`first_treat` / `covariates` / `partition` (DDD), no `_col` suffixes. Rules: +`docs/v4-design.md` section 8. ## Session Notes diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml index 2881f1688..53922823e 100644 --- a/.github/workflows/docs-tests.yml +++ b/.github/workflows/docs-tests.yml @@ -8,6 +8,7 @@ on: - 'diff_diff/**' - 'tests/test_doc_snippets.py' - 'tests/test_doc_deps_integrity.py' + - 'tests/test_v4_matrix.py' # tests/conftest.py is auto-loaded by pytest for the snippet # test run and mutates sys.path + MPLBACKEND (conftest.py:14, 18); # changes there can break snippet exec without touching the test @@ -26,6 +27,7 @@ on: - 'diff_diff/**' - 'tests/test_doc_snippets.py' - 'tests/test_doc_deps_integrity.py' + - 'tests/test_v4_matrix.py' - 'tests/conftest.py' - 'pyproject.toml' # sphinx-build job mirrors RTD setup; trigger when RTD config drifts @@ -79,6 +81,13 @@ jobs: # step does not install diff_diff. run: PYTHONPATH=. DIFF_DIFF_BACKEND=python pytest tests/test_doc_deps_integrity.py -v + - name: Run v4 deprecation-matrix enforcement + # Asserts every docs/v4-deprecations.yaml row's status against reality + # at HEAD (schema: docs/v4-design.md section 11). Runs here so + # ledger/spec-only edits (docs/** paths) cannot bypass enforcement - + # the main test matrix does not trigger on docs-only diffs. + run: PYTHONPATH=. DIFF_DIFF_BACKEND=python pytest tests/test_v4_matrix.py -v + sphinx-build: name: Sphinx HTML build (-W warnings as errors) # Skip unrelated label churn: a non-ready-for-ci label add/remove won't run this job. diff --git a/CHANGELOG.md b/CHANGELOG.md index 27843b4d2..27ecbfbcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 2015 Eq 2's ceiling where R 4.0.0 crashes by accident; R's left-side bin-edge slot-reflection quirk under empty bins is replicated for parity and documented. +- **Internal: 4.0 API design spec + machine-checked deprecation matrix.** The + normative design for the 4.0 program (three estimator merges, post-fit + `results.aggregate()`, canonical results quintet, library-wide param/column + unification, 3.9-shim-then-4.0-enforce sequencing) lives in + `docs/v4-design.md`; every deprecation's lifecycle is a row in + `docs/v4-deprecations.yaml`, enforced against reality at HEAD + by `tests/test_v4_matrix.py` (new-surface tripwires, removal pins, + default-flip and warning-retirement sweeps). Repo-internal documentation + + test only - **no public API or numerical behavior change.** ## [3.8.0] - 2026-07-18 diff --git a/CLAUDE.md b/CLAUDE.md index 5ffa3bf31..d187bb776 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,6 +176,7 @@ When adding new functionality, the source of truth is: | File | Contains | |------|----------| | `docs/methodology/REGISTRY.md` | Academic foundations, equations, edge cases — **consult before methodology changes** | +| `docs/v4-design.md` + `docs/v4-deprecations.yaml` | 4.0 program design spec + CI-enforced deprecation ledger — **consult before any 4.0-program PR**; deviations must edit both in the same diff | | `docs/doc-deps.yaml` | Source-to-documentation dependency map — **consult when any source file changes** | | `CONTRIBUTING.md` | Documentation requirements, test writing guidelines | | `.claude/commands/dev-checklists.md` | Checklists for params, methodology, warnings, reviews, bugs (run `/dev-checklists`) | diff --git a/ROADMAP.md b/ROADMAP.md index 8e28cebe0..f56b76033 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,6 +8,10 @@ Forward-looking plan for diff-diff, organized as queued work, candidates under c Queued work, ordered by expected leverage. Each item is its own PR. Ordering is priority-sequenced, not time-committed. +### 4.0 API unification + +- **The 4.0 program: estimator consolidation + one API contract.** Three merges (TwoWayFixedEffects absorbs MultiPeriodDiD, TripleDifference absorbs StaggeredTripleDifference, ChangesInChanges absorbs QDiD), post-fit `results.aggregate()`, a canonical results quintet, and library-wide column/param unification - sequenced as a 3.9 shim release (new surface + FutureWarnings), 4.0 enforcement, and a 4.1 `event_study()` comparison front door. Normative spec: `docs/v4-design.md`; every queued deprecation is tracked in `docs/v4-deprecations.yaml` and enforced by CI (`tests/test_v4_matrix.py`). + ### Practitioner-ready output - **Context-aware `practitioner_next_steps()`.** Substitutes actual column names from fitted results instead of generic placeholders, so next-step guidance is executable rather than illustrative. (Standalone follow-up to the `BusinessReport` / `DiagnosticReport` layer; tracked under the AI-Agent Track too.) diff --git a/TODO.md b/TODO.md index 6f3dc5d07..5392e2813 100644 --- a/TODO.md +++ b/TODO.md @@ -42,7 +42,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m |-------|----------|--------|--------|----------| | `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). | `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 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 | ### Testing / docs @@ -118,7 +118,7 @@ Doable in principle, but no current caller and/or explicitly out of paper scope. | Staggered/multi-period distributional DiD (Athey-Imbens Section 6 / Ciaccio arXiv:2408.01208v2; `ecic` is the staggered event-study CiC lineage - a distinct method from Ciaccio's copula approach, do not conflate). Reviewed: `docs/methodology/papers/ciaccio-2024-review.md`; ROADMAP row is reviewed-deferred pending demand. | `diff_diff/changes_in_changes.py` | #682 | Low | | ChangesInChanges treatment-on-the-controls (Athey-Imbens Theorem 3.2: group-label exchange + negation; no qte equivalent to anchor parity). | `diff_diff/changes_in_changes.py` | #682 | Low | | Rust-backend CR2 Bell-McCaffrey port (`return_dof` in the Rust vcov dispatch + CR2 algebra) — **premise re-scoped 2026-07-09**: the scores-based DOF + low-rank factored `A_g` changes made the NumPy CR2-BM path BLAS-bound (`O(n_g k²)` per cluster; 4.1s→38ms at n=100k/k=40), so a Rust port buys ~nothing and adds a parity surface. Revisit only if profiling shows CR2-BM hot again. | `rust/src/linalg.rs` | — | Low | -| Clustered-CR1 inference df **default flip to `"cluster"` (G−1) at v4** — the opt-in `df_convention=` knob landed 2026-07 (DiD/TWFE/MPD + LinearRegression; REGISTRY §TwoWayFixedEffects deviation note); the remaining work is the major-version default change (moves every clustered p-value/CI) + migration note + flipping `TestDfConvention`/`test_moderate_t_pins_residual_df_convention` expectations. Also evaluate extending the knob to standalone estimators with CR1-t inference at that time. | `diff_diff/linalg.py::LinearRegression`, `diff_diff/estimators.py`, `diff_diff/twfe.py` | — | Medium | +| Clustered-CR1 inference df **default flip to `"cluster"` (G−1) at v4** — the opt-in `df_convention=` knob landed 2026-07 (DiD/TWFE/MPD + LinearRegression; REGISTRY §TwoWayFixedEffects deviation note); the remaining work is the major-version default change (moves every clustered p-value/CI) + migration note + flipping `TestDfConvention`/`test_moderate_t_pins_residual_df_convention` expectations. Also evaluate extending the knob to standalone estimators with CR1-t inference at that time. Lifecycle tracked in docs/v4-deprecations.yaml (M-004..M-006). | `diff_diff/linalg.py::LinearRegression`, `diff_diff/estimators.py`, `diff_diff/twfe.py` | — | Medium | | CallawaySantAnna **unbalanced-panel R parity — LANDED** via `allow_unbalanced_panel=True` (matches R `did::att_gt(allow_unbalanced_panel=TRUE)` / `DRDID::reg_did_rc`: ATT bit-exact on cells AND dynamic aggregation via fixed unit-cohort-mass `pg` + a per-unit WIF; SE up to the documented CR1 `sqrt(G/(G-1))` factor). The earlier "weighting" framing was a mis-diagnosis — on unbalanced panels the dominant divergence from R is the *estimator* (within-cell differencing vs RC-on-pooled-obs), not only the weighting; both are resolved by the flag. The DEFAULT path keeps within-cell differencing as a documented design choice and now emits a `UserWarning` on unbalanced input (no-silent-failures). **Remaining deferred:** `survey_design=` × `allow_unbalanced_panel=` (per-obs vs per-unit weight resolution — currently fail-closed `NotImplementedError`); and covariate / ipw / dr × the flag R-parity verification (the RC path supports them; the committed golden covers `reg` no-cov). | `staggered.py`, `staggered_aggregation.py` | SE-audit D3 | Low | | CallawaySantAnna event-study bucket/weight construction is duplicated between the analytical aggregator (`staggered_aggregation.py::_aggregate_event_study`) and the multiplier bootstrap (`staggered_bootstrap.py`): both group (g,t) by `e = t - g`, apply the finite/NaN/reference masks, and read cohort weights. Both already consume the same source-materialized universal reference cells (so they agree), but the bucket logic is copy-pasted. Extract one shared helper returning per-event-time buckets (finite cells, NaN cells, reference flags, cohort weights, combined-IF inputs) used by both. Pure refactor; gate on byte-identical analytical + bootstrap output. | `staggered_aggregation.py`, `staggered_bootstrap.py` | SE-audit D3 | Low | | `StackedDiD` survey re-resolution intra-file dedup (raw-weight extraction ×3, compose-normalize ×3, resolve-on-stacked ×2). The cross-estimator ContinuousDiD/EfficientDiD panel-to-unit collapse consolidation LANDED (#226 shared helpers `ResolvedSurveyDesign.subset_to_units_by_row_idx` / `build_unit_first_row_index`); StackedDiD is deliberately NOT on that path (control units are duplicated across sub-experiments, so it re-resolves at stacked granularity rather than collapsing to one row per unit). The residual is stacked-specific, low value, and touches the numerically-sensitive composed-weight renormalization. Post-filter re-resolution / metadata-recompute unification across the three estimators was assessed and is not warranted — they use genuinely different mechanisms and already delegate to shared `_resolve_survey_for_fit` / `compute_survey_metadata`. | `stacked_did.py` | #226 | Low | diff --git a/docs/conf.py b/docs/conf.py index 8cd3286ee..2491fa050 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -56,6 +56,7 @@ "performance-scenarios.md", "practitioner-guide-evaluation.md", "survey-roadmap.md", + "v4-design.md", "methodology/continuous-did.md", "methodology/survey-theory.md", # Internal paper-review notes (methodology validation artifacts). diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 7be8fe6c2..cd4ab3c1b 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -209,6 +209,22 @@ where interactions are included for ALL periods (pre and post), not just post-tr Pre-treatment coefficients (e < -1) test the parallel trends assumption: under H0 of parallel trends, δ_e = 0 for all e < 0. +- **Note:** The 3.x DEFAULT implementation estimates the pooled form of this + target - treatment-group main effect + period dummies + interactions, with + NO unit fixed effects unless `absorb`/`fixed_effects` is passed + (`estimators.py` design build). The unit-FE form above (the + `fixest::feols(... | unit + time)` parity target) and the pooled default + produce identical point estimates only under balanced panels with no + covariates and simultaneous adoption; with unbalanced panels or covariates + the two projections differ. The 4.0 program (see `docs/v4-design.md` + section 4.1 and `docs/v4-deprecations.yaml` [M-010]) migrates the + event-study default to the unit-FE spec on the merged TwoWayFixedEffects, + keeping the pooled model reachable via `spec="pooled"` (required for + repeated cross-sections and for reproducing 3.x MPD numbers exactly); the + Phase 3 PR gates on a balanced-equivalence test, an + unbalanced-or-covariate divergence test, and pooled bit-exact parity vs + 3.x MPD. + Post-treatment coefficients (e ≥ 0) estimate dynamic treatment effects. Average ATT over post-treatment periods: diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml new file mode 100644 index 000000000..1660c3298 --- /dev/null +++ b/docs/v4-deprecations.yaml @@ -0,0 +1,942 @@ +# v4.0 deprecation ledger - the single source of truth for every API-lifecycle +# fact in the 4.0 program (3.9 shims -> 4.0 removals -> 5.0 alias retirement). +# +# NORMATIVE SCHEMA: docs/v4-design.md section 11 (field meanings, kind and +# status vocabularies, locator grammar, lifecycle rules). Enforced by +# tests/test_v4_matrix.py, which asserts every row's `status` against reality +# at HEAD on every CI run - a PR that changes any surface named here MUST flip +# the corresponding row in the same diff or CI fails. +# +# FORWARD-REFERENCE NOTE (for reviewers): `new` locators on `planned` rows name +# 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. Comments allowed. +# +# 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. + +rows: + # ---- Pre-existing obligations (repo sweep 2026-07-18) -------------------- + - id: M-001 + kind: param + group: obligation-sdid-params + old: "diff_diff:SyntheticDiD[lambda_reg]" + new: null + deprecated_in: "3.0.0" + removed_in: "4.0" + status: shimmed + phase: 5 + warning: DeprecationWarning + test_ref: tests/test_methodology_sdid.py + code_refs: [diff_diff/synthetic_did.py] + notes: "Ignored since 3.0.0; use zeta_omega. Removal target bumped v3.1 -> v4.0.0 in 3.1.2 (SemVer)." + - id: M-002 + kind: param + group: obligation-sdid-params + old: "diff_diff:SyntheticDiD[zeta]" + new: null + deprecated_in: "3.0.0" + removed_in: "4.0" + status: shimmed + phase: 5 + warning: DeprecationWarning + test_ref: tests/test_methodology_sdid.py + code_refs: [diff_diff/synthetic_did.py] + notes: "Ignored since 3.0.0; use zeta_lambda. Also strip the set_params deprecation branch." + - id: M-003 + kind: field + group: obligation-sdid-params + old: "diff_diff:SyntheticDiDResults.placebo_effects" + new: "diff_diff:SyntheticDiDResults.variance_effects" + deprecated_in: "3.5.2" + removed_in: "4.0" + status: shimmed + phase: 5 + warning: DeprecationWarning + test_ref: tests/test_methodology_sdid.py + code_refs: [diff_diff/results.py] + notes: "Alias property; remove together with the results.py docstring notes that reference it." + - id: M-004 + kind: default-flip + group: df-convention-flip + old: "diff_diff:DifferenceInDifferences[df_convention]" + new: null + deprecated_in: "4.0" + removed_in: null + status: planned + phase: 5 + old_default: "'residual'" + new_default: "'cluster'" + code_refs: [diff_diff/estimators.py, diff_diff/linalg.py] + notes: "Locked #663 direction; moves every clustered p-value/CI. Migration note + flip TestDfConvention expectations." + - id: M-005 + kind: default-flip + group: df-convention-flip + old: "diff_diff:TwoWayFixedEffects[df_convention]" + new: null + deprecated_in: "4.0" + removed_in: null + status: planned + phase: 5 + old_default: "'residual'" + new_default: "'cluster'" + code_refs: [diff_diff/twfe.py, diff_diff/linalg.py] + notes: "MultiPeriodDiD's knob is subsumed by its class removal [M-010]; merged event-study mode inherits this row." + - id: M-006 + kind: default-flip + group: df-convention-flip + old: "diff_diff:LinearRegression[df_convention]" + new: null + deprecated_in: "4.0" + removed_in: null + status: planned + phase: 5 + old_default: "'residual'" + new_default: "'cluster'" + code_refs: [diff_diff/linalg.py] + notes: "Evaluate extending the knob to standalone CR1-t estimators in the same PR (TODO.md row)." + - id: M-007 + kind: warning-retirement + group: obligation-warning-retirements + old: "diff_diff.estimators" + new: null + deprecated_in: "4.0" + removed_in: null + status: planned + phase: 5 + snippet: "default reference_period has changed" + code_refs: [diff_diff/estimators.py] + notes: "MPD e=-1 transition FutureWarning; dies with the MPD code path at 4.0. Merged TWFE event-study has no legacy default to warn about." + - id: M-008 + kind: env-default + group: obligation-fastpath + old: "diff_diff.linalg:_resolve_solve_ols_fastpath" + new: null + deprecated_in: null + removed_in: null + status: evaluate + phase: 5 + env_var: DIFF_DIFF_SOLVE_OLS_FASTPATH + decision_due: "4.0" + 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." + + # ---- The three merges + retiring classes --------------------------------- + - id: M-010 + kind: class + group: merge-mpd + old: "diff_diff:MultiPeriodDiD" + new: "diff_diff:TwoWayFixedEffects.fit[event_study]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 3 + warning: FutureWarning + code_refs: [diff_diff/estimators.py, diff_diff/twfe.py, diff_diff/__init__.py] + notes: "Unit-FE event study default; spec='within'|'pooled' opt-in (pooled = old MPD model, repeated cross-sections). Wild bootstrap in event-study mode = explicit error." + - id: M-011 + kind: class + group: merge-mpd + old: "diff_diff:MultiPeriodDiDResults" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 5 + code_refs: [diff_diff/results.py, diff_diff/__init__.py] + notes: "Deprecation rides parent [M-010] (returned only by MPD). Successor = unified event-study surface on the merged TWFE results." + - id: M-012 + kind: class + group: merge-mpd + old: "diff_diff:PeriodEffect" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 5 + code_refs: [diff_diff/results.py, diff_diff/__init__.py] + notes: "Per-period container superseded by the unified event-study representation (spec section 5)." + - id: M-013 + kind: class + group: merge-ddd + old: "diff_diff:StaggeredTripleDifference" + new: "diff_diff:TripleDifference.fit[first_treat]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 3 + warning: FutureWarning + code_refs: [diff_diff/triple_diff.py, diff_diff/staggered_triple_diff.py, diff_diff/__init__.py] + notes: "Facade dispatch: 2x2x2 engine vs staggered engine, both internally unchanged (R-parity preserved). Partition param unified as 'partition'. The compact 'notyettreated' control_group spelling rides this class removal - no value shim on the dying class; the unified class uses underscored values from birth." + - id: M-014 + kind: class + group: merge-ddd + old: "diff_diff:StaggeredTripleDiffResults" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 5 + code_refs: [diff_diff/staggered_triple_diff_results.py, diff_diff/__init__.py] + notes: "Deprecation rides parent [M-013]. Successor = unified TripleDifference results shape (degenerate single-ATT view for 2x2x2)." + - id: M-015 + kind: class + group: merge-qdid + old: "diff_diff:QDiD" + new: "diff_diff:ChangesInChanges[method]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 3 + warning: FutureWarning + code_refs: [diff_diff/changes_in_changes.py, diff_diff/__init__.py] + notes: "method='cic' (default) | 'qdid'; default encodes Athey-Imbens' recommendation of CiC over QDiD. Already one dispatcher/results class internally." + - id: M-016 + kind: field + group: merge-mpd + old: "diff_diff:MultiPeriodDiDResults.period_effects" + new: null + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/results.py] + notes: "Period-keyed dict becomes a FutureWarning property VIEW over the unified event-study surface on the successor container at 4.0; removed 5.0. Migrate this locator to the successor class when [M-011] flips (cross-row rule, spec section 11)." + + # ---- fit(aggregate=) -> results.aggregate(type=) ------------------------- + - id: M-020 + kind: param + group: aggregate-postfit + old: "diff_diff:CallawaySantAnna.fit[aggregate]" + new: "diff_diff:CallawaySantAnnaResults.aggregate" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/staggered.py] + notes: "balance_e moves to aggregate() in the same PR. Vocabulary: simple|event_study|group|calendar." + - id: M-021 + kind: param + group: aggregate-postfit + old: "diff_diff:ImputationDiD.fit[aggregate]" + new: "diff_diff:ImputationDiDResults.aggregate" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/imputation.py] + notes: "balance_e moves too." + - id: M-022 + kind: param + group: aggregate-postfit + old: "diff_diff:TwoStageDiD.fit[aggregate]" + new: "diff_diff:TwoStageDiDResults.aggregate" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/two_stage.py] + notes: "balance_e moves too." + - id: M-023 + kind: param + group: aggregate-postfit + old: "diff_diff:EfficientDiD.fit[aggregate]" + new: "diff_diff:EfficientDiDResults.aggregate" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/efficient_did.py] + notes: "balance_e moves too." + - id: M-024 + kind: param + group: aggregate-postfit + old: "diff_diff:StackedDiD.fit[aggregate]" + new: "diff_diff:StackedDiDResults.aggregate" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/stacked_did.py] + notes: "StackedDiD's 'simple' value already in-vocabulary." + - id: M-025 + kind: param + group: aggregate-postfit + old: "diff_diff:ContinuousDiD.fit[aggregate]" + new: "diff_diff:ContinuousDiDResults.aggregate" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/continuous_did.py] + notes: "The no-underscore 'eventstudy' spelling dies with this param; aggregate() accepts only the unified vocabulary (+ 'dose' as this estimator's extra level)." + - id: M-026 + kind: param + group: aggregate-postfit + old: "diff_diff:ChaisemartinDHaultfoeuille.fit[aggregate]" + new: "diff_diff:ChaisemartinDHaultfoeuilleResults.aggregate" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py] + notes: "dCDH placebo/normalized surfaces stay estimator-native; only the aggregation entry point moves." + - id: M-027 + kind: param + group: aggregate-postfit + old: "diff_diff:HeterogeneousAdoptionDiD.fit[aggregate]" + new: "diff_diff:HeterogeneousAdoptionDiDResults.aggregate" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/had.py] + notes: "HAD levels: overall -> simple, event_study unchanged." + + # ---- Contract renames (params) ------------------------------------------- + - id: M-030 + kind: param + group: renames-post + old: "diff_diff:DifferenceInDifferences.fit[time]" + new: "diff_diff:DifferenceInDifferences.fit[post]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/estimators.py] + notes: "2x2 'time' is a 0/1 post dummy - same name as the calendar column everywhere else with a different meaning (the library's worst overload)." + - id: M-031 + kind: param + group: renames-post + old: "diff_diff:TripleDifference.fit[time]" + new: "diff_diff:TripleDifference.fit[post]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: null + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/triple_diff.py] + notes: "Same post-dummy overload as [M-030], but the NAME 'time' persists as the merged class's staggered calendar column, so removed_in is null - the 4.0 semantic enforcement is [M-085] (mirrors the TWFE pair [M-082]/[M-083])." + - id: M-032 + kind: param + group: renames-cohort + old: "diff_diff:WooldridgeDiD.fit[cohort]" + new: "diff_diff:WooldridgeDiD.fit[first_treat]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/wooldridge.py] + notes: "Only estimator using 'cohort' for the first-treatment column; 12 others use first_treat." + - id: M-033 + kind: param + group: renames-dcdh + old: "diff_diff:ChaisemartinDHaultfoeuille.fit[group]" + new: "diff_diff:ChaisemartinDHaultfoeuille.fit[unit]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py] + notes: "'group' here means the unit id (R DIDmultiplegt vocabulary) but means the treated-group 0/1 indicator in TripleDifference - name-carries-altered-meaning violation." + - id: M-034 + kind: param + group: renames-dcdh + old: "diff_diff:ChaisemartinDHaultfoeuille.fit[controls]" + new: "diff_diff:ChaisemartinDHaultfoeuille.fit[covariates]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py] + notes: "Majority spelling is covariates." + - id: M-035 + kind: param + group: renames-col-suffix + old: "diff_diff:HeterogeneousAdoptionDiD.fit[outcome_col]" + new: "diff_diff:HeterogeneousAdoptionDiD.fit[outcome]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/had.py] + notes: "_col suffix used only by HAD and RDD; 22 other estimators use bare names." + - id: M-036 + kind: param + group: renames-col-suffix + old: "diff_diff:HeterogeneousAdoptionDiD.fit[dose_col]" + new: "diff_diff:HeterogeneousAdoptionDiD.fit[dose]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/had.py] + notes: "Matches ContinuousDiD's bare 'dose'." + - id: M-037 + kind: param + group: renames-col-suffix + old: "diff_diff:HeterogeneousAdoptionDiD.fit[time_col]" + new: "diff_diff:HeterogeneousAdoptionDiD.fit[time]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/had.py] + notes: "" + - id: M-038 + kind: param + group: renames-col-suffix + old: "diff_diff:HeterogeneousAdoptionDiD.fit[unit_col]" + new: "diff_diff:HeterogeneousAdoptionDiD.fit[unit]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/had.py] + notes: "" + - id: M-039 + kind: param + group: renames-col-suffix + old: "diff_diff:HeterogeneousAdoptionDiD.fit[first_treat_col]" + new: "diff_diff:HeterogeneousAdoptionDiD.fit[first_treat]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/had.py] + notes: "" + - id: M-040 + kind: param + group: renames-col-suffix + old: "diff_diff:RegressionDiscontinuity.fit[outcome_col]" + new: "diff_diff:RegressionDiscontinuity.fit[outcome]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/rdd.py] + notes: "" + - id: M-041 + kind: param + group: renames-col-suffix + old: "diff_diff:RegressionDiscontinuity.fit[running_col]" + new: "diff_diff:RegressionDiscontinuity.fit[running]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/rdd.py] + notes: "" + - id: M-042 + kind: param + group: renames-col-suffix + old: "diff_diff:RegressionDiscontinuity.fit[treatment_col]" + new: "diff_diff:RegressionDiscontinuity.fit[treatment]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/rdd.py] + notes: "0/1 take-up column for fuzzy RD - consistent with 'treatment' as a 0/1 indicator library-wide." + - id: M-043 + kind: param + group: renames-control-group + old: "diff_diff:StackedDiD[clean_control]" + new: "diff_diff:StackedDiD[control_group]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/stacked_did.py] + notes: "Same concept as control_group elsewhere; underscored value spellings win library-wide (spec section 8)." + - id: M-044 + kind: param + group: renames-level + old: "diff_diff:WooldridgeDiDResults.to_dataframe[aggregation]" + new: "diff_diff:WooldridgeDiDResults.to_dataframe[level]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/wooldridge_results.py] + notes: "Every other to_dataframe selector is level=. Covers ONLY the to_dataframe param rename; the 'event' value vocabulary across Wooldridge's aggregation surfaces is [M-086], and the summary(aggregation=) surface is [M-087]." + - id: M-045 + kind: param + group: renames-robust-drop + old: "diff_diff:DifferenceInDifferences[robust]" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/estimators.py] + notes: "Redundant with vcov_type; inherited by TwoWayFixedEffects (and MultiPeriodDiD until [M-010])." + - id: M-046 + kind: param + group: renames-robust-drop + old: "diff_diff:TripleDifference[robust]" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/triple_diff.py] + notes: "" + - id: M-047 + kind: param + group: renames-robust-drop + old: "diff_diff:HeterogeneousAdoptionDiD[robust]" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/had.py] + notes: "Only estimator where robust defaults False - a third meaning for the same knob." + + # ---- Results-field storage flips (4.0 native quintet) -------------------- + # Canonical att/se/t_stat/p_value/conf_int already exist as PROPERTIES on + # every class (verified 2026-07-18); these rows track the 4.0 STORAGE flip: + # canonical becomes the dataclass field, overall_* becomes the warning + # property (removed 5.0). Headline pair asserted; sibling overall_se/ + # overall_p_value/overall_conf_int/overall_t_stat fields flip in the same PR. + - id: M-050 + kind: field + group: field-flip + old: "diff_diff:CallawaySantAnnaResults.overall_att" + new: "diff_diff:CallawaySantAnnaResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + test_ref: null + code_refs: [diff_diff/staggered_results.py] + notes: "Pickle migration via __setstate__ per spec section 5." + - id: M-051 + kind: field + group: field-flip + old: "diff_diff:SunAbrahamResults.overall_att" + new: "diff_diff:SunAbrahamResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/sun_abraham.py] + notes: "" + - id: M-052 + kind: field + group: field-flip + old: "diff_diff:ImputationDiDResults.overall_att" + new: "diff_diff:ImputationDiDResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/imputation_results.py] + notes: "" + - id: M-053 + kind: field + group: field-flip + old: "diff_diff:TwoStageDiDResults.overall_att" + new: "diff_diff:TwoStageDiDResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/two_stage_results.py] + notes: "" + - id: M-054 + kind: field + group: field-flip + old: "diff_diff:StackedDiDResults.overall_att" + new: "diff_diff:StackedDiDResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/stacked_did_results.py] + notes: "" + - id: M-055 + kind: field + group: field-flip + old: "diff_diff:EfficientDiDResults.overall_att" + new: "diff_diff:EfficientDiDResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/efficient_did_results.py] + notes: "" + - id: M-056 + kind: field + group: field-flip + old: "diff_diff:WooldridgeDiDResults.overall_att" + new: "diff_diff:WooldridgeDiDResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/wooldridge_results.py] + notes: "" + - id: M-057 + kind: field + group: field-flip + old: "diff_diff:ChaisemartinDHaultfoeuilleResults.overall_att" + new: "diff_diff:ChaisemartinDHaultfoeuilleResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/chaisemartin_dhaultfoeuille_results.py] + notes: "" + - id: M-058 + kind: field + group: field-flip + old: "diff_diff:ContinuousDiDResults.overall_att" + new: "diff_diff:ContinuousDiDResults.att" + introduced_in: "4.0" + deprecated_in: "4.0" + removed_in: "5.0" + status: planned + phase: 5 + warning: FutureWarning + code_refs: [diff_diff/continuous_did_results.py] + notes: "Also flips the deviant overall_att_se/overall_att_p_value/overall_att_conf_int/overall_att_t_stat sibling fields (the trap-catalog outlier)." + + # ---- Alias table --------------------------------------------------------- + - id: M-060 + kind: alias + group: alias-table + old: "EventStudy" + new: null + old_target: "diff_diff:MultiPeriodDiD" + new_target: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 3 + code_refs: [diff_diff/__init__.py] + notes: "DROPPED, not retargeted: 'event study' is a design produced by CS/SA/BJS/LPDiD too, and retargeting to a class whose default is the static ATT would carry altered meaning. Deprecation warning rides parent [M-010] - the alias IS the same class object, so instantiating via EventStudy hits MPD's shim warning; no separate alias warning is enforceable." + - id: M-061 + kind: alias + group: alias-table + old: "QDiDResults" + new: null + old_target: "diff_diff:ChangesInChangesResults" + new_target: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 5 + code_refs: [diff_diff/__init__.py] + notes: "Already a pure export alias of ChangesInChangesResults; dies with QDiD [M-015]." + - id: M-064 + kind: alias + group: alias-table + old: "SDDD" + new: null + old_target: "diff_diff:StaggeredTripleDifference" + new_target: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 3 + code_refs: [diff_diff/__init__.py] + notes: "Dies with its class [M-013]; deprecation warning rides the parent class shim (same object). Migrate the old_target locator when [M-013] flips to removed (cross-row rule)." + - id: M-062 + kind: alias + group: alias-table + old: "SCM" + new: null + old_target: null + new_target: "diff_diff:SyntheticControl" + introduced_in: "3.9" + deprecated_in: null + removed_in: null + status: planned + phase: 2 + code_refs: [diff_diff/__init__.py] + notes: "Introduce-only. 'SC' rejected: one transposition from the existing CS alias. TROP/LPDiD are self-aliased acronyms; no rows." + - id: M-063 + kind: alias + group: alias-table + old: "Spillover" + new: null + old_target: null + new_target: "diff_diff:SpilloverDiD" + introduced_in: "3.9" + deprecated_in: null + removed_in: null + status: planned + phase: 2 + code_refs: [diff_diff/__init__.py] + notes: "Introduce-only; follows the Stacked=StackedDiD precedent." + + # ---- Module-level function wrappers (class form is canonical) ------------ + - id: M-070 + kind: function + group: function-wrappers + old: "diff_diff:imputation_did" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/imputation.py, diff_diff/__init__.py] + notes: "Use ImputationDiD. Only 8 of 24 estimators ever had wrappers; the surface is retired rather than completed." + - id: M-071 + kind: function + group: function-wrappers + old: "diff_diff:two_stage_did" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/two_stage.py, diff_diff/__init__.py] + notes: "Use TwoStageDiD." + - id: M-072 + kind: function + group: function-wrappers + old: "diff_diff:stacked_did" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/stacked_did.py, diff_diff/__init__.py] + notes: "Use StackedDiD." + - id: M-073 + kind: function + group: function-wrappers + old: "diff_diff:trop" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/trop.py, diff_diff/__init__.py] + notes: "Use TROP." + - id: M-074 + kind: function + group: function-wrappers + old: "diff_diff:synthetic_control" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/synthetic_control.py, diff_diff/__init__.py] + notes: "Use SyntheticControl." + - id: M-075 + kind: function + group: function-wrappers + old: "diff_diff:triple_difference" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/triple_diff.py, diff_diff/__init__.py] + notes: "Use TripleDifference." + - id: M-076 + kind: function + group: function-wrappers + old: "diff_diff:bacon_decompose" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/bacon.py, diff_diff/__init__.py] + notes: "Use BaconDecomposition. twowayfeweights stays - it is a diagnostic function, not a class duplicate." + - id: M-077 + kind: function + group: function-wrappers + old: "diff_diff:chaisemartin_dhaultfoeuille" + new: null + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py, diff_diff/__init__.py] + notes: "Use ChaisemartinDHaultfoeuille." + + - id: M-086 + kind: param-value + group: renames-level + old: "diff_diff:WooldridgeDiDResults.aggregate[type]=event" + new: "diff_diff:WooldridgeDiDResults.aggregate[type]=event_study" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/wooldridge_results.py] + notes: "Wooldridge's EXISTING post-fit aggregate() (emfx-style prior art for spec section 6) accepts the drifted 'event' spelling; unifies to 'event_study' across aggregate/summary/to_dataframe value vocabularies. 'gt' stays as a documented estimator extra (group-time table), like ContinuousDiD's 'dose'." + - id: M-087 + kind: param + group: renames-level + old: "diff_diff:WooldridgeDiDResults.summary[aggregation]" + new: null + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/wooldridge_results.py] + notes: "summary() unifies to the library-wide summary(alpha=None) signature (spec section 5); aggregation selection lives on aggregate(). The alpha param arrives additively in the same PR." + + # ---- Behavior policies (schema-tracked, spec-governed; no reality probe) - + - id: M-080 + kind: behavior + group: policy-auto-cluster + old: "diff_diff:TwoWayFixedEffects[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]." + - id: M-081 + kind: behavior + group: policy-n-bootstrap + old: "diff_diff:DifferenceInDifferences[n_bootstrap]" + new: null + deprecated_in: null + removed_in: null + status: planned + phase: 2 + code_refs: [diff_diff/estimators.py, diff_diff/synthetic_did.py, diff_diff/changes_in_changes.py] + notes: "Semantic unification only: n_bootstrap=0 = bootstrap off wherever an analytical path exists; per-estimator counts stay tuned (999 light / 200 compute-heavy). No numeric default changes." + - id: M-082 + kind: param + group: renames-post + old: "diff_diff:TwoWayFixedEffects.fit[time]" + new: "diff_diff:TwoWayFixedEffects.fit[post]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: null + status: planned + phase: 3 + warning: FutureWarning + code_refs: [diff_diff/twfe.py] + notes: "Static TWFE's 'time' is a 0/1 post dummy today; static mode migrates to 'post'. The NAME 'time' persists (it becomes the event-study calendar column), so removed_in is null - the 4.0 semantic enforcement is [M-083]." + - id: M-083 + kind: behavior + group: merge-mpd + old: "diff_diff:TwoWayFixedEffects.fit[time]" + new: null + deprecated_in: "4.0" + removed_in: null + status: planned + phase: 5 + code_refs: [diff_diff/twfe.py] + notes: "At 4.0, 'time' means calendar ONLY: static mode (event_study=False) passing time= raises a ValueError pointing at post= - never silently reinterpreted as a post dummy. Companion to [M-082]." + - id: M-085 + kind: behavior + group: merge-ddd + old: "diff_diff:TripleDifference.fit[time]" + new: null + deprecated_in: "4.0" + removed_in: null + status: planned + phase: 5 + code_refs: [diff_diff/triple_diff.py] + notes: "At 4.0, 'time' means the staggered calendar column ONLY: 2x2x2 mode passing time= raises a ValueError pointing at post= - never silently reinterpreted. Companion to [M-031]; mirrors [M-083]." + - id: M-084 + kind: param + group: constructor-hygiene + old: "diff_diff:ContinuousDiD[covariates]" + new: "diff_diff:ContinuousDiD.fit[covariates]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: planned + phase: 2 + warning: FutureWarning + code_refs: [diff_diff/continuous_did.py] + notes: "The one estimator with a design-matrix column in the constructor; moves to fit() per the sklearn split (spec section 7)." diff --git a/docs/v4-design.md b/docs/v4-design.md new file mode 100644 index 000000000..74eddbac0 --- /dev/null +++ b/docs/v4-design.md @@ -0,0 +1,546 @@ +# diff-diff 4.0 Design Specification + +**Status: LIVING - normative until the 4.0 cut.** + +**Review contract:** every Phase 2-5 PR of the 4.0 program is reviewed against +this document. A PR that deviates from it must edit this document (and the +matrix, `docs/v4-deprecations.yaml`) in the same diff - deviation-by-silence is +a review reject. + +**Forward-reference disclaimer:** symbols described here as 3.9/4.0 surface +intentionally do NOT exist at HEAD. `tests/test_v4_matrix.py` enforces their +absence until their phase ships, and flips to enforcing their presence when the +matrix row flips. Nothing in this document is an undocumented deviation; it is +the documentation. + +Companion artifacts: `docs/v4-deprecations.yaml` (the lifecycle ledger - single +source of truth for every old/new/version/status fact; this prose never +restates per-row lifecycle data and instead cites rows as `[M-###]`), +`tests/test_v4_matrix.py` (enforcement), ROADMAP.md "4.0 API unification" +(program pointer). + +All decisions below were locked with the maintainer on 2026-07-18 (six program +decisions + eight naming-checkpoint decisions). Sections 3-8 are the target +surface; section 9 maps it onto PRs. + +--- + +## 1. Goals / Non-goals + +**Goals.** (i) Three estimator merges: TwoWayFixedEffects absorbs +MultiPeriodDiD [M-010], TripleDifference absorbs StaggeredTripleDifference as +a facade [M-013], ChangesInChanges absorbs QDiD [M-015]. (ii) One contract +across everything that stays separate: column vocabulary (section 8), results +quintet (section 5), aggregation (section 6), inference surface (section 7), +alias table (section 3). (iii) Every deprecation queued anywhere in the repo +lands or is explicitly re-scheduled at the 4.0 cut - tracked by the matrix, +enforced by CI. + +**Non-goals.** No numerical behavior changes in any phase except the two +scheduled default policies ([M-004]..[M-006] df_convention, [M-080] +auto-cluster) and the documented estimate/SE shift of the MPD merge +(section 4.1). No +new estimators. No merging across identification strategies: the staggered +family (CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, EfficientDiD, +WooldridgeDiD, StackedDiD, LPDiD) stays separate classes - ecosystem-wide, +method switches succeed only when estimators share an estimand and input +contract, and these differ structurally (ImputationDiD/TwoStageDiD have no +control-group concept at all). SyntheticControl and SyntheticDiD stay separate +(disjoint weight machinery). The 4.1 `event_study()` comparison front door is +sketched in section 9 but not specified here. + +## 2. Release-line rule and warning policy + +**Ladder:** 3.9 ships the new surface additively plus FutureWarnings on old +surface (sklearn playbook) -> 4.0 removes deprecated surface, flips results +storage (section 5) and the two default policies -> 4.1 adds the front door -> +5.0 removes the 4.0-era compatibility properties. Last fully-old-API release: +3.8.0 (tag `v3.8.0`). + +**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). + +**Warning categories:** all NEW shims emit `FutureWarning` (visible to end +users by default). Pre-existing `DeprecationWarning` sites keep their category +([M-001] [M-002] [M-003]) - churn-free. + +**Per-PR gates.** Every shim PR ships a dedicated behavioral test file +asserting (a) `pytest.warns` on the old surface with the migration message, and +(b) bit-exact routing parity: the deprecated path routes to the same numbers as +before the PR. Every removal PR follows the +`tests/test_had_dual_knob_deprecation.py` pattern: canonical-surface positive +smoke + `TypeError`/`AttributeError` removal pin per surface. Matrix rows flip +in the same diff; the PR's CHANGELOG entry names the flipped row ids. + +## 3. Target 4.0 surface + +### 3.1 Estimator roster (fates) + +Derived from `diff_diff.__all__` at v3.8.0. 24 estimator classes -> 21. + +| Class | Fate | +|---|---| +| DifferenceInDifferences | Keep (2x2; `post` contract, section 8) | +| TwoWayFixedEffects | Keep + absorbs MultiPeriodDiD [M-010] | +| MultiPeriodDiD | Removed 4.0 [M-010] (results class [M-011], PeriodEffect [M-012]) | +| CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, EfficientDiD, WooldridgeDiD, StackedDiD, LPDiD | Keep (staggered family - separate by design) | +| TripleDifference | Keep + absorbs StaggeredTripleDifference [M-013] | +| StaggeredTripleDifference | Removed 4.0 [M-013] (results class [M-014]) | +| ChangesInChanges | Keep + absorbs QDiD via `method=` [M-015] | +| QDiD | Removed 4.0 [M-015] | +| SyntheticDiD, SyntheticControl, TROP, ContinuousDiD, HeterogeneousAdoptionDiD, RegressionDiscontinuity, SpilloverDiD, ChaisemartinDHaultfoeuille | Keep | +| BaconDecomposition | Keep (documented as a diagnostic, not an estimator, in the API docs and alias-table grouping) | + +### 3.2 Final alias table + +Existing aliases keep their targets (DiD, TWFE, SDiD, CS, SA, BJS, Gardner, +DDD, Stacked, Bacon, EDiD, ETWFE, DCDH, CiC, CDiD, HAD, RDD). Changes: +`EventStudy` is dropped, not retargeted [M-060] - "event study" names a design +that CS/SA/BJS/LPDiD also produce, and retargeting it to a class whose default +mode is the static ATT would make the name carry altered meaning. `SDDD` dies +with its class [M-013] [M-064]. `QDiDResults` dies with QDiD [M-061]. New: `SCM` for +SyntheticControl [M-062] ("SC" rejected - one transposition from CS) and +`Spillover` for SpilloverDiD [M-063]. TROP and LPDiD are self-aliased acronyms. +After 4.0: every estimator has exactly one class name and at most one alias. + +### 3.3 Module-level function wrappers + +The 8 estimator wrapper functions are deprecated in 3.9 and removed in 4.0 +([M-070]..[M-077]): classes are the single canonical construction surface. +`twowayfeweights` stays (diagnostic function, not a class duplicate); +`compute_honest_did`, placebo/power helpers, and dataset loaders are +unaffected (not estimator duplicates). + +### 3.4 Canonical signatures for renamed surfaces + +Only surfaces with matrix rows are listed; unchanged estimators keep their +signatures. Lifecycle facts live in the cited rows. + +- `DifferenceInDifferences.fit(data, outcome=None, treatment=None, post=None, + formula=None, covariates=None, fixed_effects=None, absorb=None, + survey_design=None, unit=None)` - `time` -> `post` [M-030]. +- `TwoWayFixedEffects.fit(..., post=...)` in static mode - `time` -> `post` + [M-082], with the 4.0 static-mode `time=` rejection [M-083]; section 4.1. +- `ContinuousDiD.fit(..., covariates=...)` - `covariates` moves from + `__init__` to `fit()` [M-084] (the sklearn hyperparam/data split). +- `TripleDifference.fit(...)` - section 4.2; `time` -> `post` in 2x2x2 mode + [M-031], with the NAME persisting as the staggered calendar column and the + 4.0 2x2x2-mode `time=` rejection [M-085] (mirrors the TWFE pair). +- `WooldridgeDiD.fit(..., first_treat=...)` - `cohort` -> `first_treat` + [M-032]. The `exovar`/`xtvar`/`xgvar` covariate split is KEPT intentionally + (the three-way split is methodologically meaningful: which covariates enter + which interaction sets); documented as domain vocabulary under the section 8 + rules. +- `ChaisemartinDHaultfoeuille.fit(..., unit=..., covariates=...)` - `group` -> + `unit` [M-033], `controls` -> `covariates` [M-034]. +- `HeterogeneousAdoptionDiD.fit(outcome=, dose=, time=, unit=, first_treat=, + ...)` - `_col` suffixes dropped [M-035]..[M-039]. +- `RegressionDiscontinuity.fit(outcome=, running=, treatment=, ...)` - + [M-040]..[M-042]. +- `StackedDiD(control_group=...)` - `clean_control` renamed [M-043]. +- `WooldridgeDiDResults.to_dataframe(level=...)` - `aggregation` -> `level` + [M-044]; the `"event"` value spelling unifies across its existing + `aggregate(type=)` surface [M-086] and `summary(aggregation=)` is retired + for the uniform `summary(alpha=None)` [M-087]. +- `robust` constructor param dropped everywhere it exists + [M-045]..[M-047] - fully redundant with `vcov_type`, and its default even + differed across estimators (True/True/False). + +## 4. The three merges + +### 4.1 TwoWayFixedEffects absorbs MultiPeriodDiD [M-010] + +**Target API.** + +```python +TWFE().fit(df, outcome, treatment, post, unit) # static ATT + # (post = 0/1 dummy) +TWFE().fit(df, outcome, treatment, time, unit, + event_study=True) # dynamic mode + # (time = calendar) +TWFE().fit(df, outcome, treatment, time, + event_study=True, spec="pooled") # old MPD model; + # repeated cross-sections +``` + +**The static `time`/`post` contract [M-082] [M-083].** Today's static TWFE +takes its 0/1 post dummy in a param NAMED `time` (the code and REGISTRY warn +on >2 unique values) - the same overload section 8 rule 1 abolishes. The +merged class resolves it without silent reinterpretation: static mode takes +`post` (the semantics of today's `time` argument, renamed [M-082]); +event-study mode takes calendar `time`. In 3.9, static `time=` still works +with a FutureWarning steering to `post=`. At 4.0, `time` means calendar ONLY, +and static mode (`event_study=False`) passing `time=` raises a `ValueError` +pointing at `post=` [M-083] - the name is never quietly re-meant. + +Event-study mode params: `event_study: bool = False`, `spec: str = "within"` +(`"within"` = unit + time FE; `"pooled"` = treatment-group dummy + period +dummies, no unit FE - the only spec that works without a unit id), +`reference_period=None` (default: last pre-period, e=-1 convention - no +transition warning; the legacy-default FutureWarning dies with MPD [M-007]), +`post_periods=None`. + +**Routing semantics.** `event_study=False` with `post=` is numerically +exactly today's TWFE. With `event_study=True`, `spec="within"` estimates the +unit-FE event study (`alpha_i + gamma_t + sum_e delta_e * D_i x 1[t=e]`); +`spec="pooled"` reproduces the MPD design matrix. There is NO auto-detection +from the presence of `unit` - spec is always explicit (rejected: silent +estimand switches). `spec="within"` without `unit` is an error. + +**Documented estimate shift.** MPD's default spec had no unit FE; the merged +default does. Point estimates coincide ONLY in the restricted equivalence +case - balanced panel, no covariates, simultaneous adoption (REGISTRY section +MultiPeriodDiD); with unbalanced panels or covariates, the unit-FE projection +changes point estimates too, and standard errors shift in general. 3.x MPD +numbers are reproduced exactly via `spec="pooled"` - that, not "only SEs +move", is the migration message. Phase 3 test obligations: (a) balanced +no-covariate equivalence test (`within` == `pooled` point estimates), (b) an +unbalanced-or-covariate divergence test (`within` != `pooled`, locking the +documented behavior change), (c) `spec="pooled"` bit-exact parity vs 3.x MPD. +Migration guide gets a worked example of both specs. + +**Inference.** The merged class carries TWFE's inference stack: auto-cluster +at unit (section 7), wild bootstrap for the static mode. Wild bootstrap in +event-study mode raises an explicit `ValueError` at 4.0 (MPD's current silent +analytical fallback violates the no-silent-failures principle); porting it is +backlog, not scope. + +**Results.** 3.9's `TWFE(event_study=True)` returns the unified event-study +surface (section 5) from day one - no intermediate container churn. +`period_effects` becomes a FutureWarning property view over that surface at +4.0, removed 5.0 [M-016]. The HonestDiD / PreTrendsPower integrations (which +read `interaction_indices` off MultiPeriodDiDResults) are ported to the +unified surface in the same Phase 3 PR. + +**Deprecation choreography.** 3.9: `event_study=`/`spec=` ship on TWFE; +`MultiPeriodDiD.__init__` emits FutureWarning; `EventStudy` alias warns +[M-060]. 4.0: MultiPeriodDiD, MultiPeriodDiDResults, PeriodEffect, EventStudy +removed [M-010] [M-011] [M-012] [M-060]. + +### 4.2 TripleDifference absorbs StaggeredTripleDifference [M-013] + +**Target API.** + +```python +ddd = TripleDifference() +ddd.fit(df, outcome, group, partition, post) # 2x2x2 (RC engine) +ddd.fit(df, outcome, unit, time, first_treat, partition, # staggered (panel engine) + covariates=None, ...) +``` + +**Routing semantics.** One class, two engines, both internally UNCHANGED in +this program (R-parity preserved; engine unification is a possible 4.x +internal refactor, explicitly out of scope). Dispatch is by signature shape: +the 2x2x2 engine serves the `(group, partition, post)` parameterization; the +staggered engine serves `(unit, time, first_treat, partition)`. Mixing the +two parameter sets is an error, not a guess. This mirrors the reference +implementation - Ortiz-Villavicencio & Sant'Anna's own `triplediff::ddd()` +serves both designs from one signature. The third dimension is `partition` in +both modes (the paper's and R package's vocabulary); the staggered engine's +`eligibility` name dies with its class. + +**Inference note.** The two engines keep their existing inference stacks +(analytical influence-function SEs on the 2x2x2 engine; multiplier bootstrap + +GMM weighting on the staggered engine). `cluster=` analytical SEs remain +staggered-mode-unsupported (bootstrap required), as today - documented in the +class docstring, tracked as post-4.0 backlog. + +**Results.** One results shape: the staggered container's structure with the +2x2x2 case as the degenerate single-ATT view (canonical quintet always +populated; group-time table empty in 2x2x2 mode). StaggeredTripleDiffResults +dies [M-014]. + +**Deprecation choreography.** 3.9: staggered params ship on TripleDifference; +`time` -> `post` in 2x2x2 mode [M-031] (the `time` NAME persists as the +staggered calendar column - same contract as TWFE's [M-082]); +StaggeredTripleDifference warns. 4.0: StaggeredTripleDifference + results + +SDDD alias removed [M-013] [M-014] [M-064]; 2x2x2 mode passing `time=` +raises with `post=` guidance [M-085], never silently reinterpreted. + +### 4.3 ChangesInChanges absorbs QDiD [M-015] + +**Target API.** `ChangesInChanges(method="cic")` with `method="qdid"` for the +quantile-DiD comparison estimator. The default encodes Athey & Imbens' +recommendation of CiC over QDiD. Internally these are already one dispatcher, +one bootstrap machinery, one results class - the merge is API-only. 3.9: +`method=` ships; QDiD class warns. 4.0: QDiD + QDiDResults export alias +removed [M-015] [M-061]. + +## 5. Results contract + +**Canonical quintet.** `att`, `se`, `t_stat`, `p_value`, `conf_int` - bound to +ONE coherent inference row (locked library principle: uniform names never +carry altered meaning). As of v3.8.0 every results 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 +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 +`avg_*` family dies with its class [M-011] instead of flipping. + +**Serialization policy.** `to_dict()` / `to_dataframe()` emit canonical names +only from 4.0 (deprecated names never leak into serialized output; the +warning properties are attribute-access-only). `to_dataframe`'s selector is +`level=` everywhere [M-044]; `summary(alpha=None)` is the uniform signature. +Every main results class supports `summary()`, `to_dict()`, `to_dataframe()` - +the seven classes currently missing `to_dict()` gain it in Phase 2 +(additive). + +**Unified event-study representation.** ONE representation for event-study +effects across all estimators (today there are five). The unified surface is +specified in the Phase 2 results-base PR plan (per the section 9 boundary +rule - only that PR cares about the container's internals); this document +pins the requirements it must satisfy: per-event-time canonical quintet rows, +explicit reference-period marking (no sentinel-value conventions - the +n_groups==0 / n_obs==0 sentinels are retired), the event-study vcov exposed +uniformly where computed, and `to_dataframe(level="event_study")` emitting +identical column schemas from every estimator. + +**Pickle migration.** Renamed-field classes ship `__setstate__` migration +following the existing `SyntheticDiDResults.__setstate__` precedent +(diff_diff/results.py) so 3.x pickles load under 4.0. + +## 6. Aggregation contract: `results.aggregate(type=...)` + +**Pattern.** Estimate once, aggregate as a post-fit step - the ecosystem's +strongest norm (`did::aggte`, `etwfe::emfx`, Stata `estat aggregation`). +`fit(aggregate=)` is deprecated in 3.9 and removed in 4.0 +([M-020]..[M-027]); `balance_e` moves to `aggregate()` alongside it. + +**Vocabulary.** Closed set: `"simple"`, `"event_study"`, `"group"`, +`"calendar"`, plus per-estimator documented extras where the estimand demands +them (ContinuousDiD adds `"dose"` [M-025]; HAD's `"overall"` maps to +`"simple"` [M-027]; Wooldridge's `"gt"` group-time table stays as a +documented extra). The drifted spellings die across ALL their surfaces: +`"eventstudy"` (ContinuousDiD [M-025]); Wooldridge's `"event"` on its +EXISTING post-fit `aggregate(type=)` - the emfx-style prior art for this +section's pattern - plus `summary(aggregation=)` and +`to_dataframe(aggregation=)` [M-044] [M-086] [M-087]. + +**Semantics.** `aggregate()` re-aggregates WITHOUT refitting, from influence +functions / bootstrap draws retained on the results object (CallawaySantAnna +already stores them; the Phase 2 PR extends retention where missing - memory +cost documented per estimator). Analytical-vs-bootstrap inference of the +aggregated estimand follows the fit's inference method. Estimators whose +estimand is already a single aggregation (SunAbraham's saturated event study, +LPDiD's per-horizon design) expose `aggregate()` where meaningful as additive +surface; their native params (`only_event`/`only_pooled` etc.) are documented +domain vocabulary, not drift. + +## 7. Inference surface + +- `vcov_type` becomes the single SE-type selector wherever analytical SEs + exist; the redundant `robust` flag dies [M-045]..[M-047]. Estimators without + analytical SEs (SyntheticControl, TROP, CiC) document their native variance + methods instead of growing a dead param. +- Wild bootstrap is exposed via `inference="wild_bootstrap"` uniformly where + supported (today's split between `inference=` and `n_bootstrap>0` idioms is + resolved in the Phase 2 plan for the affected estimators). +- `n_bootstrap` semantic unification [M-081]: `0` = bootstrap off wherever an + analytical path exists; bootstrap-only estimators document their positive + defaults. Counts stay tuned per estimator (999 light / 200 compute-heavy) - + NO numeric default changes. +- **Auto-cluster policy** [M-080], flips at 4.0: every panel estimator + (required `unit` column) defaults to clustering at unit + (Bertrand-Duflo-Mullainathan practice), setting `cluster_name` / + `n_clusters` metadata (locked cluster-label rule; verify each SE formula at + implementation per that rule). `cluster=False` explicitly disables. + 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. +- **df_convention default flip** [M-004]..[M-006]: `"residual"` -> + `"cluster"` (G-1) at 4.0 - the locked #663 direction. Moves every clustered + p-value/CI; the flip PR updates `TestDfConvention` / + `test_moderate_t_pins_residual_df_convention` expectations, adds the + migration-guide entry, and evaluates extending the knob to standalone CR1-t + estimators (TODO row). +- 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 24 + hand-rolled `get_params`/`set_params` copies (transactional set_params per + the locked rule; `deep=` supported uniformly). + +## 8. Contract-rename rules + +The rules; the complete rename inventory is `docs/v4-deprecations.yaml` +(groups `renames-*`). **This section is normative for any rename the matrix +missed.** + +1. `post` names a 0/1 post-period indicator; `time` always means the calendar + period column. Never overload [M-030] [M-031] [M-082] [M-083] [M-085]. +2. `first_treat` names the cohort / first-treatment-period column everywhere + [M-032]. +3. `unit` names the unit identifier; `group` is reserved for the treated-group + 0/1 indicator (TripleDifference) [M-033]. +4. `covariates` names covariate column lists [M-034]; estimator-specific + covariate structure may keep domain names when the split is methodological + (Wooldridge `exovar`/`xtvar`/`xgvar` - kept). +5. No `_col` suffixes [M-035]..[M-042]. +6. `control_group` is the param, `"never_treated"` / `"not_yet_treated"` + (underscored) the values [M-043]. LPDiD's `"clean"` is kept as domain + vocabulary (clean-control design, a different concept), documented in its + docstring with the mapping. +7. `treatment` names a 0/1 indicator (group membership or take-up), never a + cohort and never a treatment level; estimators with non-binary + per-period treatment (dCDH) document theirs as domain vocabulary. +8. Hybrid-naming principle (locked 2026-07-12): diff-diff names where the + library owns the concept; domain vocabulary where it is the field's + language; an R-equivalents mapping table (`yname/tname/idname/gname` -> + `outcome/time/unit/first_treat`) ships in the docs, not as param aliases. + +**Naming-checkpoint outcomes (2026-07-18), with losing candidates:** +`event_study=` bool (over `effects=` enum, `dynamic=` bool); +`spec="within"|"pooled"` (over `unit_fe=` bool, `model=` string); +`partition` (over `eligibility`); EventStudy alias dropped (over retarget - +altered-meaning trap - and over a convenience subclass); underscored +control_group values (over R-compact spellings, over accept-both); +unified event-study representation with `period_effects` as a 4.0->5.0 +property (over keeping the dict canonical, over hard removal); n_bootstrap +semantic-only unification (over uniform 999, over uniform 200); panel +auto-cluster-at-unit (over never-auto-cluster, over status quo). + +## 9. Phase -> PR breakdown + +Boundary rule, verbatim: **anything two later PRs could disagree about lives +above; anything only one PR cares about stays in that PR's plan.** + +| Phase | Ships in | PRs (each: dedicated shim/removal tests + matrix flips + CHANGELOG naming flipped row ids) | +|---|---|---| +| 1 (this PR) | - | Spec + matrix + enforcement test + support edits | +| 2: contract foundations | 3.9 | (a) results base + unified event-study representation + to_dict completion; (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027]; (c) param renames [M-030..M-047] + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introductions [M-062] [M-063] + wrapper deprecations [M-070..M-077] + n_bootstrap docs [M-081] | +| 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] (gates: section 4.1's equivalence/divergence/pooled-parity test triple); (b) TripleDifference facade [M-013]; (c) CiC method= [M-015] | +| 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-016, M-030..M-047 old names, M-060, M-061, M-070..M-077, M-001..M-003]; storage flips [M-050..M-058]; default policies [M-004..M-006, M-080]; warning retirement [M-007]; fastpath go/no-go [M-008]; 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) | + +**4.0-cut checklist (final item):** the due-row sweep is AUTOMATED - +`tests/test_v4_matrix.py::test_due_rows_are_terminal` fails any release bump +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. + +## 10. Migration-guide skeleton (`docs/migration-4.0.md`, written in Phase 4) + +1. TL;DR table: one row per breaking change, old -> new, one-line fix. +2. The three merges (worked examples: MPD -> TWFE event_study incl. the SE + shift and `spec="pooled"`; SDDD -> TripleDifference; QDiD -> CiC method=). +3. Renamed parameters (generated from the matrix, groups `renames-*`). +4. `results.aggregate()` (before/after snippets per estimator family). +5. Results fields (overall_att family -> canonical quintet; property window). +6. Inference defaults that moved numbers (df_convention, auto-cluster) - with + how to reproduce 3.x numbers exactly. +7. Removed functions and aliases (wrappers, EventStudy, SDDD, QDiDResults). +8. Codemod section: the mechanical renames as a script/regex table. + +## 11. Matrix mechanics (normative schema for `docs/v4-deprecations.yaml`) + +**Fields.** `id` (`M-###`, unique, never reused), `kind`, `group` (required +- human clustering key), `old`, +`new`, `introduced_in`, `deprecated_in`, `removed_in`, `status`, `phase`, +`warning`, `test_ref`, `code_refs`, `notes`, plus kind-specific: +`old_default`/`new_default` (default-flip), `snippet` (warning-retirement), +`old_target`/`new_target` (alias), `env_var` + `decision_due` + +`decided_default` (env-default: `decision_due` is the version by which the +go/no-go must be recorded; `decided_default: on|off` records the outcome and +is required at `done` - "evaluated, kept off" is a first-class terminal +state). `deprecated_in`/`removed_in`/`new` are required-present but nullable; +versions match `\d+\.\d+(\.\d+)?`. + +**Kinds.** `param` (constructor or method parameter), `param-value` (accepted +value spelling; full symbol lifecycle and due gate but NO reality probe - +accepted values are not introspectable, so behavioral enforcement lives in +the row's `test_ref` suite), `class`, `field` (results attribute), +`function`, `alias` (top-level export alias), `default-flip` (same param, new +default), `env-default` (environment-variable-resolved default), +`warning-retirement` (a warning message scheduled to disappear), `behavior` +(policy change not assertable by introspection; schema-checked only, flipped +manually, swept at the cut). + +**Locator grammar.** `diff_diff:Name` (top-level export - REQUIRED for +class/function rows with `removed_in` set, so the locator survives module +deletion), `diff_diff:Class[param]` (`__init__` parameter), +`diff_diff:Class.method[param]`, `diff_diff:Class.attr` (field/property), +dotted module path (warning-retirement/env-default rows). The module part +must always import - a failed import is a hard test failure (typo guard), +never a legal absence. + +**Status lifecycle.** Symbol kinds (param/class/field/function): `planned` -> +`shimmed` -> `removed`; the `shimmed` stop may be skipped only when the row's +deprecation rides a parent row (stated in `notes`, e.g. [M-011]). +`param-value` rows follow the same lifecycle (with `test_ref` required at +`shimmed`/`removed` and due-gate coverage). Remaining non-symbol kinds +(alias/default-flip/env-default/warning-retirement/behavior): `planned` +or `evaluate` -> `done`. Terminal rows (`removed`/`done`) keep asserting +forever - a removed symbol resurrecting is a test failure. + +**Assertion semantics (enforced by `tests/test_v4_matrix.py`).** +- `param`/`class`/`function`: `planned` = old resolves AND new does NOT + resolve; `shimmed` = both resolve + `test_ref` exists; `removed` = old + absent, new (if non-null) resolves, `test_ref` exists. +- `field`: membership in `__dataclass_fields__` is the discriminator. + `planned` = old is a dataclass field, new is NOT a dataclass field + (property is fine); `shimmed` = new is the dataclass field, old resolves as + a descriptor but is NOT a dataclass field; `removed` = old absent entirely. +- `alias`: `planned` with `old_target` = identity holds + (`getattr(diff_diff, old) is resolve(old_target)`); introduce-only rows + (`old_target: null`) = name absent from `diff_diff`; `done` = identity with + `new_target`, or absent if `new_target` is null. +- `default-flip`: `inspect.signature` default equals `old_default` + (`done`: equals `new_default`). +- `env-default`: resolver imports and returns False with the env var deleted + (`done`: the resolver matches `decided_default` with the env var unset - + "on" expects True, "off" expects False). +- `warning-retirement`: `snippet` present in the `code_refs` file (`done`: + absent). +- `param-value`: schema + due gate + `test_ref` existence at + `shimmed`/`removed`; no reality probe (value behavior asserted in the + `test_ref` suite: old spelling warns at shim, rejected at removal, new + spelling accepted). +- `behavior`: schema + `code_refs` existence only. +- Release gate (all kinds): once `diff_diff.__version__` reaches a row's + `removed_in` (symbol/alias rows), flip version (`deprecated_in` on + default-flip/warning-retirement/behavior rows), or `decision_due` + (env-default rows), the status must be terminal; a due `introduced_in` + means the row may no longer be `planned` (the new surface must have + shipped - this is what gates introduce-only aliases); symbol rows with a + due `deprecated_in` and a declared `warning` may no longer be `planned`. + The gate is two-sided: an EARLY-removal guard fails any row that goes + terminal while its scheduled version is still in the future (the shim + window is a promise). Field-flip rows assert the whole family: at the 4.0 + flip, the full canonical quintet must be native fields and none of the + ROW'S deprecated sibling names (both conventions: `overall_se`-style and + `overall_att_se`-style) may remain fields - partial migrations fail. Other + `overall_*` estimand families (e.g. ContinuousDiD's `overall_acrt*`) are + separate estimands outside the quintet contract, untouched unless they get + their own rows. Version comparisons pad to three components + ('4.0' == '4.0.0'). + env-default `done` asserts the resolver matches `decided_default` with the + env var unset - flip-on and evaluated-kept-off are both representable. A + declared `test_ref` must exist at EVERY status, including terminal + (removal pins survive forever); behavior / default-flip / env-default rows + REQUIRE a `test_ref` at `done` (semantic flips need ledger-linked + behavioral evidence). Alias rows must NOT declare `warning` - an alias is + the same object as its target, so the deprecation warning rides the parent + class row (schema-enforced). Top-level `diff_diff:Name` class/function rows + and alias rows also assert `__all__` membership consistent with their + status (stale `import *` entries fail). The initial 71 row ids are a + committed snapshot in the enforcement test: ids are never deleted or + reused, and the test fails if any snapshot id disappears. + +**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 +enforcement test's hard-fail on unresolvable module parts makes forgetting +this loud, not silent. + +**`phase` semantics.** `phase` names the program phase whose PR performs the +row's NEXT status transition; each flip updates it (terminal rows keep their +last value). + +## 12. Open questions + +None. diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py new file mode 100644 index 000000000..4d823d12b --- /dev/null +++ b/tests/test_v4_matrix.py @@ -0,0 +1,924 @@ +""" +Enforcement for the 4.0 deprecation ledger (``docs/v4-deprecations.yaml``). + +Every ledger row records the lifecycle of one API surface in the 4.0 program +(``docs/v4-design.md`` section 11 is the normative schema). This module asserts, +on every run, that each row's ``status`` matches reality at HEAD: + +- ``planned`` symbol rows: the old surface resolves and the new surface does + NOT - so a PR that ships new surface without flipping its row fails CI (the + tripwire that keeps the ledger honest through Phases 2-5). +- ``shimmed`` rows: both surfaces resolve and the row's dedicated behavioral + test file exists (warning-emission itself is asserted THERE, not here - this + module is pure introspection, no fits, no data). +- ``removed``/``done`` rows keep asserting forever (anti-resurrection guard). + +FORWARD-REFERENCE NOTE: ``new`` locators on ``planned`` rows name 3.9/4.0 +surface that intentionally does not exist yet; this module asserts its ABSENCE. + +The release-cut sweep is AUTOMATED: ``test_due_rows_are_terminal`` fails any +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. + +Parsing is a purpose-built line scanner (restricted-YAML format contract in the +ledger header) because the project ships no PyYAML - same precedent as +``tests/test_doc_deps_integrity.py``. A dev-side dual-parse test keeps the file +honest as real YAML wherever PyYAML happens to be installed. +""" + +import importlib +import inspect +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +MATRIX = REPO_ROOT / "docs" / "v4-deprecations.yaml" +SPEC = REPO_ROOT / "docs" / "v4-design.md" + +# Repo-integrity test: reads docs/ artifacts relative to the repo root. The installed-package CI +# matrix copies tests/ to a temp dir where docs/ is absent - nothing to validate there, so skip +# the whole module (same guard as tests/test_doc_deps_integrity.py). +if not MATRIX.exists(): + pytest.skip( + f"{MATRIX} not found - v4-matrix enforcement runs only from the source tree, not against " + "an installed package copied to a temp dir.", + allow_module_level=True, + ) + +# --------------------------------------------------------------------------- +# Schema vocabulary (normative home: docs/v4-design.md section 11) +# --------------------------------------------------------------------------- +SYMBOL_KINDS = {"param", "class", "field", "function"} +NONSYMBOL_KINDS = { + "param-value", + "alias", + "default-flip", + "env-default", + "warning-retirement", + "behavior", +} +KINDS = SYMBOL_KINDS | NONSYMBOL_KINDS +# param-value rows share the symbol LIFECYCLE (planned -> shimmed -> removed, test_ref at +# shim/removal, due-gated) but skip reality probes - accepted values are not introspectable; +# their behavioral enforcement lives in the row's test_ref suite. +LIFECYCLE_KINDS = SYMBOL_KINDS | {"param-value"} +SYMBOL_STATUSES = {"planned", "shimmed", "removed"} +NONSYMBOL_STATUSES = {"planned", "evaluate", "done"} +WARNING_VALUES = {"FutureWarning", "DeprecationWarning"} +KNOWN_FIELDS = { + "id", + "kind", + "group", + "old", + "new", + "introduced_in", + "deprecated_in", + "removed_in", + "status", + "phase", + "warning", + "test_ref", + "code_refs", + "notes", + "old_default", + "new_default", + "snippet", + "old_target", + "new_target", + "env_var", + "decision_due", + "decided_default", +} +REQUIRED_FIELDS = { + "id", + "kind", + "group", + "old", + "new", + "deprecated_in", + "removed_in", + "status", + "phase", +} +_VERSION_RE = re.compile(r"^\d+\.\d+(\.\d+)?$") +_ID_RE = re.compile(r"^M-\d{3}$") +_ROW_START_RE = re.compile(r"^ - id:\s*(\S+)\s*$") +_FIELD_RE = re.compile(r"^ ([a-z_]+):\s*(.*?)\s*$") +_MD_TOKEN_RE = re.compile(r"\[(M-\d{3})\]") + +# Row-count floor: exactly the 71 rows shipped at Phase 1. 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 = 71 + +# Committed snapshot of the Phase 1 id set ("ids are never deleted or reused" contract - a +# delete-one-add-one edit keeps the count above the floor but trips this). Extend, never edit. +_INITIAL_ID_RANGES = [(1, 8), (10, 16), (20, 27), (30, 47), (50, 58), (60, 64), (70, 77), (80, 87)] +EXPECTED_INITIAL_IDS = frozenset( + f"M-{n:03d}" for lo, hi in _INITIAL_ID_RANGES for n in range(lo, hi + 1) +) + + +def _parse_scalar(raw): + """Decode one restricted-YAML scalar: quotes stripped, ``null`` -> None, flow list -> list.""" + if raw == "null" or raw == "": + return None + if raw.startswith("[") and raw.endswith("]"): + inner = raw[1:-1].strip() + return [] if not inner else [item.strip().strip("\"'") for item in inner.split(",")] + if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")): + return raw[1:-1] + return raw + + +def parse_matrix(text): + """Parse the restricted-YAML ledger; returns (rows, errors). + + Format errors (unknown field, content outside a row, no ``rows:`` header) are collected + rather than raised so schema tests can report them all at once. + """ + rows, errors = [], [] + in_rows = False + current = None + for lineno, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not in_rows: + if stripped == "rows:": + in_rows = True + continue + m = _ROW_START_RE.match(line) + if m: + current = {"id": m.group(1)} + rows.append(current) + continue + m = _FIELD_RE.match(line) + if m and current is not None: + key, raw = m.group(1), m.group(2) + if key not in KNOWN_FIELDS: + errors.append(f"line {lineno}: unknown field '{key}' (row {current['id']})") + continue + if key in current and key != "id": + errors.append(f"line {lineno}: duplicate field '{key}' in row {current['id']}") + continue + current[key] = _parse_scalar(raw) + continue + errors.append(f"line {lineno}: unparseable line inside rows block: {stripped!r}") + if not in_rows: + errors.append("no top-level 'rows:' key found") + return rows, errors + + +def validate_schema(rows): + """Return a list of schema-violation strings (empty = valid).""" + errors = [] + seen_ids = set() + for row in rows: + rid = row.get("id", "") + if not _ID_RE.match(rid): + errors.append(f"{rid}: id does not match M-###") + if rid in seen_ids: + errors.append(f"{rid}: duplicate id") + seen_ids.add(rid) + missing = REQUIRED_FIELDS - set(row) + if missing: + errors.append(f"{rid}: missing required field(s) {sorted(missing)}") + continue + kind, status = row["kind"], row["status"] + if kind not in KINDS: + errors.append(f"{rid}: unknown kind '{kind}'") + continue + legal = SYMBOL_STATUSES if kind in LIFECYCLE_KINDS else NONSYMBOL_STATUSES + if status not in legal: + errors.append( + f"{rid}: status '{status}' illegal for kind '{kind}' (legal: {sorted(legal)})" + ) + for vfield in ("introduced_in", "deprecated_in", "removed_in", "decision_due"): + val = row.get(vfield) + if val is not None and not _VERSION_RE.match(str(val)): + errors.append(f"{rid}: {vfield}={val!r} does not match \\d+.\\d+(.\\d+)?") + if row.get("warning") is not None and row["warning"] not in WARNING_VALUES: + errors.append(f"{rid}: warning={row['warning']!r} not in {sorted(WARNING_VALUES)}") + if kind in LIFECYCLE_KINDS and status in ("shimmed", "removed") and not row.get("test_ref"): + errors.append( + f"{rid}: status '{status}' requires a test_ref (dedicated behavioral test)" + ) + if not row.get("code_refs"): + errors.append(f"{rid}: code_refs must be a non-empty list") + if kind == "default-flip" and not (row.get("old_default") and row.get("new_default")): + errors.append(f"{rid}: default-flip requires old_default and new_default") + if kind == "warning-retirement" and not row.get("snippet"): + errors.append(f"{rid}: warning-retirement requires a snippet") + if kind == "env-default" and not row.get("env_var"): + errors.append(f"{rid}: env-default requires env_var") + if ( + kind == "env-default" + and status == "done" + and row.get("decided_default") not in ("on", "off") + ): + errors.append( + f"{rid}: env-default at 'done' requires decided_default: on|off " + "(evaluated-kept-off is a first-class outcome)" + ) + if ( + kind in ("behavior", "default-flip", "env-default") + and status == "done" + and not row.get("test_ref") + ): + errors.append( + f"{rid}: {kind} at 'done' requires a test_ref (semantic flips need " + "ledger-linked behavioral evidence)" + ) + if kind == "alias" and ("old_target" not in row or "new_target" not in row): + errors.append(f"{rid}: alias requires old_target and new_target (nullable)") + if kind == "alias" and row.get("warning") is not None: + errors.append( + f"{rid}: alias rows must not declare 'warning' - an alias is the same object " + "as its target, so the deprecation warning rides the parent class row" + ) + if ( + kind in ("class", "function") + and row.get("removed_in") is not None + and not str(row["old"]).startswith("diff_diff:") + ): + errors.append( + f"{rid}: removable {kind} rows must use a top-level 'diff_diff:' locator " + f"(survives module deletion), got {row['old']!r}" + ) + return errors + + +# --------------------------------------------------------------------------- +# Locator resolution +# --------------------------------------------------------------------------- +_LOCATOR_RE = re.compile(r"^(?P[\w.]+)(?::(?P[\w.]+))?(?:\[(?P\w+)\])?$") + + +def _import_module_hard(modname, rid): + """Module import is the typo guard: failure is ALWAYS a test failure, never a legal absence.""" + try: + return importlib.import_module(modname) + except ImportError as exc: # pragma: no cover - exercised only on locator typos + pytest.fail(f"{rid}: locator module '{modname}' failed to import ({exc}) - typo'd locator?") + + +def resolve_locator(locator, rid): + """Resolve ``module[:Attr.chain][ [param] ]``; return (resolved: bool, detail: str). + + The module part must import (hard failure otherwise). Attribute-chain or parameter absence is + the legal, assertable signal and returns (False, why). + """ + m = _LOCATOR_RE.match(locator) + if m is None or (m.group("param") and not m.group("attrs")): + pytest.fail(f"{rid}: locator {locator!r} does not match the grammar (spec section 11)") + obj = _import_module_hard(m.group("mod"), rid) + attrs = (m.group("attrs") or "").split(".") if m.group("attrs") else [] + for i, attr in enumerate(attrs): + nxt = ( + inspect.getattr_static(obj, attr, None) + if not inspect.ismodule(obj) or i > 0 + else getattr(obj, attr, None) + ) + if nxt is None: + nxt = getattr(obj, attr, None) + if nxt is None: + return False, f"attribute '{attr}' absent on {'.'.join([m.group('mod')] + attrs[:i]) }" + obj = nxt + param = m.group("param") + if param is None: + return True, "resolved" + target = obj + if inspect.isclass(target): + target = target.__init__ + if isinstance(target, (staticmethod, classmethod, property)): + target = getattr(target, "__func__", getattr(target, "fget", target)) + try: + sig = inspect.signature(target) + except (TypeError, ValueError) as exc: + pytest.fail(f"{rid}: cannot take signature of {locator!r} ({exc})") + if param in sig.parameters: + return True, "param present" + if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()): + pytest.fail( + f"{rid}: param '{param}' not found but {locator!r} has **kwargs - absence is " + "unprovable; use a precise locator or a dedicated row kind (spec section 11)" + ) + return False, f"param '{param}' absent" + + +def _top_level_export_name(locator): + """Return the export name for a ``diff_diff:Name`` locator (no dots, no [param]); else None.""" + m = _LOCATOR_RE.match(locator) + if m and m.group("mod") == "diff_diff" and m.group("attrs") and not m.group("param"): + if "." not in m.group("attrs"): + return m.group("attrs") + return None + + +def _check_all_membership(all_names, name, expect_present): + """Pure check: is ``name``'s presence in ``__all__`` consistent with expectation?""" + return (name in all_names) is expect_present + + +def _locator_class_and_attr(locator, rid): + """For field locators ``diff_diff:Class.attr``: return (class object, attr name).""" + m = _LOCATOR_RE.match(locator) + if m is None or not m.group("attrs") or m.group("param"): + pytest.fail(f"{rid}: field locator {locator!r} must be 'module:Class.attr'") + mod = _import_module_hard(m.group("mod"), rid) + attrs = m.group("attrs").split(".") + obj = mod + for attr in attrs[:-1]: + obj = getattr(obj, attr, None) + if obj is None: + pytest.fail(f"{rid}: class part of field locator {locator!r} does not resolve") + return obj, attrs[-1] + + +def _signature_default(locator, rid): + """Return repr(default) for a ``Class[param]`` / ``Class.method[param]`` locator.""" + m = _LOCATOR_RE.match(locator) + if m is None or not m.group("param"): + pytest.fail(f"{rid}: default-flip locator {locator!r} must carry a [param]") + resolved, _ = resolve_locator(locator, rid) + if not resolved: + pytest.fail(f"{rid}: default-flip param in {locator!r} does not exist") + mod = _import_module_hard(m.group("mod"), rid) + obj = mod + for attr in m.group("attrs").split("."): + obj = getattr(obj, attr) + if inspect.isclass(obj): + obj = obj.__init__ + return repr(inspect.signature(obj).parameters[m.group("param")].default) + + +# --------------------------------------------------------------------------- +# Load the real ledger once +# --------------------------------------------------------------------------- +ROWS, _PARSE_ERRORS = parse_matrix(MATRIX.read_text()) +_ROW_IDS = [r.get("id", "?") for r in ROWS] + + +def test_matrix_parses_cleanly(): + assert not _PARSE_ERRORS, "format errors in docs/v4-deprecations.yaml:\n" + "\n".join( + _PARSE_ERRORS + ) + + +def test_matrix_parsed_nonempty(): + """Guard: a scanner/format break must not silently green the reality checks below.""" + assert len(ROWS) >= ROW_COUNT_FLOOR, ( + f"only {len(ROWS)} rows parsed (floor {ROW_COUNT_FLOOR}) - scanner/format drift? " + "Rows are never deleted, so the ledger only grows." + ) + + +def test_matrix_schema_valid(): + errors = validate_schema(ROWS) + assert not errors, "schema violations in docs/v4-deprecations.yaml:\n" + "\n".join(errors) + + +def test_spec_tokens_resolve(): + """Every [M-###] token in docs/v4-design.md must name a ledger row (anti-drift crossref).""" + assert SPEC.exists(), "docs/v4-design.md missing while the ledger exists" + tokens = set(_MD_TOKEN_RE.findall(SPEC.read_text())) + known = set(_ROW_IDS) + dangling = sorted(tokens - known) + assert not dangling, f"spec cites ledger rows that do not exist: {dangling}" + + +@pytest.mark.parametrize("row", ROWS, ids=_ROW_IDS) +def test_code_refs_exist(row): + """code_refs must exist for non-terminal rows; test_ref must exist at ANY status. + + Terminal rows skip only the code_refs check (the referenced code may legitimately be + deleted); their removal-pin test file must survive forever, so a declared test_ref is + always validated (verify-paths discipline). + """ + tref = row.get("test_ref") + if tref: + assert (REPO_ROOT / tref).exists(), f"{row['id']}: test_ref '{tref}' does not exist" + if row.get("status") in ("removed", "done"): + return + for ref in row.get("code_refs") or []: + assert (REPO_ROOT / ref).exists(), f"{row['id']}: code_ref '{ref}' does not exist" + + +def _version_tuple(version): + """Parse '3.9' / '4.0.0' (ignoring any local suffix) into a comparable int 3-tuple. + + Padded to exactly three components so '4.0' and '4.0.0' compare EQUAL - unpadded + tuples would make (4, 0) < (4, 0, 0) and silently skip a due gate. + """ + core = re.split(r"[+a-zA-Z]", str(version), maxsplit=1)[0].rstrip(".") + parts = [int(p) for p in core.split(".")] + return tuple(parts + [0] * (3 - len(parts)))[:3] + + +def collect_due_problems(rows, current): + """Pure due-row sweep over parsed rows at version tuple ``current`` (unit-testable). + + Rules (spec section 11 release gate): + - symbol rows past ``removed_in`` must be ``removed``; alias rows must be ``done``; + - default-flip / warning-retirement / behavior rows past their flip version + (``deprecated_in``) must be ``done``; + - env-default rows past ``decision_due`` must be ``done`` (go/no-go recorded either way); + - ANY row past ``introduced_in`` may no longer be ``planned`` (the new surface must have + shipped - this is what gates introduce-only aliases); + - symbol rows that declare a ``warning`` and are past ``deprecated_in`` must no longer + be ``planned`` (their shim must have shipped); + - 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. + """ + 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") + 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 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}'") + if kind == "alias" and status != "done": + problems.append(f"{rid}: removed_in {removed_in} is due but status is '{status}'") + if kind in ("default-flip", "warning-retirement", "behavior"): + flip = row.get("deprecated_in") + if flip and current >= _version_tuple(flip) and status != "done": + problems.append(f"{rid}: flip version {flip} is due but status is '{status}'") + decision_due = row.get("decision_due") + if kind == "env-default" and decision_due and current >= _version_tuple(decision_due): + if status != "done": + problems.append( + f"{rid}: decision_due {decision_due} is due but status is '{status}' " + "(record the go/no-go either way)" + ) + introduced_in = row.get("introduced_in") + if introduced_in and current >= _version_tuple(introduced_in) and status == "planned": + problems.append( + f"{rid}: introduced_in {introduced_in} is due but status is still 'planned' " + "(new surface not shipped?)" + ) + if ( + kind in LIFECYCLE_KINDS + and row.get("warning") + and row.get("deprecated_in") + and current >= _version_tuple(row["deprecated_in"]) + and status == "planned" + ): + problems.append( + f"{rid}: deprecated_in {row['deprecated_in']} is due but status is still " + "'planned' (shim not shipped?)" + ) + return problems + + +def test_due_rows_are_terminal(): + """Release gate: the automated form of the 4.0-cut sweep (spec section 9). A release + bump whose version reaches a row's scheduled version fails CI unless the row flipped + or was explicitly re-scheduled in the release PR.""" + import diff_diff + + problems = collect_due_problems(ROWS, _version_tuple(diff_diff.__version__)) + 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) + ) + + +def test_initial_ids_never_deleted(): + """The Phase 1 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.""" + missing = sorted(EXPECTED_INITIAL_IDS - set(_ROW_IDS)) + assert not missing, f"ledger rows deleted (ids are permanent): {missing}" + assert len(EXPECTED_INITIAL_IDS) == 71 + + +def test_version_tuple_pads_to_three_components(): + """'4.0' and '4.0.0' must compare EQUAL - unpadded tuples would skip due gates.""" + assert _version_tuple("4.0") == _version_tuple("4.0.0") == (4, 0, 0) + assert _version_tuple("3.9") < _version_tuple("4.0.0") + overdue = { + "id": "M-905", + "kind": "param", + "old": "diff_diff:SyntheticDiD[lambda_reg]", + "new": None, + "deprecated_in": "3.0.0", + "removed_in": "4.0.0", + "status": "shimmed", + "phase": 5, + } + # current version stated with two components, row with three: still due + assert collect_due_problems([overdue], _version_tuple("4.0")) + + +def test_all_membership_helper_semantics(): + """Pure semantics of the __all__ consistency check used by the reality tests.""" + assert _check_all_membership(["A", "B"], "A", True) + assert _check_all_membership(["A", "B"], "C", False) + assert not _check_all_membership(["A", "B"], "Stale", True) # missing but expected present + assert not _check_all_membership(["A", "Stale"], "Stale", False) # stale entry not removed + + +@pytest.mark.parametrize( + ("row_overrides", "current", "expected_substring", "clean_at"), + [ + # overdue introduce-only alias: introduced_in due but still planned + ( + { + "id": "M-901", + "kind": "alias", + "old": "SCM", + "new": None, + "old_target": None, + "new_target": "diff_diff:SyntheticControl", + "introduced_in": "3.9", + "deprecated_in": None, + "removed_in": None, + "status": "planned", + "phase": 2, + }, + (3, 9, 0), + "introduced_in 3.9 is due", + (3, 8, 0), + ), + # overdue env-default evaluation: decision_due due but not done + ( + { + "id": "M-902", + "kind": "env-default", + "old": "diff_diff.linalg:_resolve_solve_ols_fastpath", + "new": None, + "deprecated_in": None, + "removed_in": None, + "decision_due": "4.0", + "status": "evaluate", + "phase": 5, + }, + (4, 0, 0), + "decision_due 4.0 is due", + (3, 9, 0), + ), + # overdue removal: removed_in due but still shimmed + ( + { + "id": "M-903", + "kind": "param", + "old": "diff_diff:SyntheticDiD[lambda_reg]", + "new": None, + "deprecated_in": "3.0.0", + "removed_in": "4.0", + "status": "shimmed", + "phase": 5, + }, + (4, 0, 0), + "removed_in 4.0 is due", + (3, 9, 0), + ), + # overdue param-value removal: value migrations are due-gated like symbol rows + ( + { + "id": "M-906", + "kind": "param-value", + "old": "diff_diff:WooldridgeDiDResults.aggregate[type]=event", + "new": "diff_diff:WooldridgeDiDResults.aggregate[type]=event_study", + "deprecated_in": "3.9", + "removed_in": "4.0", + "status": "shimmed", + "phase": 5, + }, + (4, 0, 0), + "removed_in 4.0 is due", + (3, 9, 0), + ), + # EARLY removal: row flipped to removed while removed_in is still in the future + ( + { + "id": "M-904", + "kind": "param", + "old": "diff_diff:SyntheticDiD[lambda_reg]", + "new": None, + "deprecated_in": "3.0.0", + "removed_in": "5.0", + "status": "removed", + "phase": 5, + }, + (4, 0, 0), + "still in the future", + (5, 0, 0), + ), + ], + ids=[ + "overdue-introduce-only-alias", + "overdue-env-default-decision", + "overdue-removal", + "overdue-param-value-removal", + "early-removal-before-schedule", + ], +) +def test_due_gate_catches_overdue_rows(row_overrides, current, expected_substring, clean_at): + """Negative coverage for the release gate: each bad shape must be reported at ``current`` + and clean at ``clean_at`` (the gate is version-driven, not static).""" + problems = collect_due_problems([row_overrides], current) + assert any( + expected_substring in p for p in problems + ), f"expected a due-gate problem containing {expected_substring!r}, got: {problems}" + assert not collect_due_problems([row_overrides], clean_at) + + +@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).""" + rid, kind, status = row["id"], row["kind"], row["status"] + old, new = row["old"], row["new"] + + if kind == "param-value": + pytest.skip( + "no reality probe (accepted values are not introspectable); lifecycle is " + "schema+due-gate enforced and value behavior lives in the row's test_ref suite" + ) + if kind == "behavior": + pytest.skip("schema-checked kind; flipped manually, swept at the cut") + + if kind in ("param", "class", "function"): + if status == "planned": + resolved, detail = resolve_locator(old, rid) + assert resolved, f"{rid} planned but old {old!r} does not resolve ({detail})" + if new is not None: + resolved, detail = resolve_locator(new, rid) + assert not resolved, ( + f"{rid} planned but new {new!r} ALREADY resolves - the owning PR must flip " + f"this row to 'shimmed' in the same diff (docs/v4-design.md section 11)" + ) + elif status == "shimmed": + resolved, detail = resolve_locator(old, rid) + assert resolved, f"{rid} shimmed but old {old!r} does not resolve ({detail})" + if new is not None: + resolved, detail = resolve_locator(new, rid) + assert resolved, f"{rid} shimmed but new {new!r} does not resolve ({detail})" + elif status == "removed": + resolved, _ = resolve_locator(old, rid) + assert not resolved, f"{rid} removed but old {old!r} still resolves (resurrection?)" + if new is not None: + resolved, detail = resolve_locator(new, rid) + assert resolved, f"{rid} removed but new {new!r} does not resolve ({detail})" + if _top_level_export_name(old) is not None: + import diff_diff + + name = _top_level_export_name(old) + expect = status != "removed" + assert _check_all_membership(diff_diff.__all__, name, expect), ( + f"{rid}: '{name}' should {'be' if expect else 'NOT be'} in diff_diff.__all__ at " + f"status '{status}' (stale __all__ entries break `from diff_diff import *`)" + ) + + elif kind == "field": + cls, old_attr = _locator_class_and_attr(old, rid) + fields = getattr(cls, "__dataclass_fields__", {}) + if status == "planned": + assert old_attr in fields, f"{rid} planned but '{old_attr}' is not a dataclass field" + if new is not None: + _, new_attr = _locator_class_and_attr(new, rid) + assert new_attr not in fields, ( + f"{rid} planned but '{new_attr}' is ALREADY a dataclass field - flip the row " + "in the storage-flip PR (a property is expected and fine at this status)" + ) + elif status == "shimmed": + assert ( + old_attr not in fields + ), f"{rid} shimmed but '{old_attr}' is still a dataclass field (flip not shipped?)" + assert ( + inspect.getattr_static(cls, old_attr, None) is not None + ), f"{rid} shimmed but '{old_attr}' does not resolve as a descriptor" + if new is not None: + _, new_attr = _locator_class_and_attr(new, rid) + assert new_attr in fields, f"{rid} shimmed but '{new_attr}' is not the native field" + elif status == "removed": + assert ( + inspect.getattr_static(cls, old_attr, None) is None and old_attr not in fields + ), f"{rid} removed but '{old_attr}' still present (resurrection?)" + # Family checks are ADDITIVE to the status branches above (a plain `if`, never part of + # the status elif chain - review round 6 caught the base assertions being skipped). + if row.get("group") == "field-flip": + # The flip is a FAMILY move (spec section 5): the headline pair is the assertable + # anchor, but the whole canonical quintet and every overall_* sibling flip together. + quintet = ("att", "se", "t_stat", "p_value", "conf_int") + if status == "planned": + for name in quintet: + assert inspect.getattr_static(cls, name, None) is not None, ( + f"{rid}: canonical '{name}' no longer resolves on {cls.__name__} - the " + "3.x property surface regressed" + ) + elif status in ("shimmed", "removed"): + # Keeps asserting at 'removed' too (post-5.0): terminal rows guard forever + # against quintet regressions or deprecated siblings resurrecting as native. + for name in quintet: + assert name in fields, ( + f"{rid} {status} but canonical '{name}' is not a native dataclass field " + f"on {cls.__name__} - partial quintet migration" + ) + # Row-specific deprecated family, NOT a broad overall_* prefix match - other + # overall_* estimand families (e.g. ContinuousDiD's overall_acrt*) are separate + # estimands governed by their own rows, if any. Both sibling conventions are + # covered: CS-style (overall_att -> overall_se) and ContinuousDiD-style + # (overall_att -> overall_att_se). + _, new_attr = _locator_class_and_attr(new, rid) + family = {old_attr} + for canonical in quintet[1:]: + family.add(old_attr.replace(new_attr, canonical, 1)) + family.add(f"{old_attr}_{canonical}") + leftovers = sorted(f for f in fields if f in family) + assert not leftovers, ( + f"{rid} {status} but deprecated siblings remain native fields on " + f"{cls.__name__}: {leftovers} - the family flips together" + ) + + elif kind == "alias": + import diff_diff + + if status in ("planned", "evaluate"): + if row["old_target"] is None: + assert not hasattr(diff_diff, old), ( + f"{rid} introduce-only alias '{old}' ALREADY exists - flip the row in the " + "introducing PR" + ) + alias_in_all = False + else: + target_resolved = _resolve_alias_target(row["old_target"], rid) + assert ( + getattr(diff_diff, old, None) is target_resolved + ), f"{rid}: alias '{old}' is not identical to {row['old_target']}" + alias_in_all = True + else: # done + if row["new_target"] is None: + assert not hasattr(diff_diff, old), f"{rid} done but alias '{old}' still exists" + alias_in_all = False + else: + target_resolved = _resolve_alias_target(row["new_target"], rid) + assert ( + getattr(diff_diff, old, None) is target_resolved + ), f"{rid} done but alias '{old}' does not point at {row['new_target']}" + alias_in_all = True + assert _check_all_membership(diff_diff.__all__, old, alias_in_all), ( + f"{rid}: alias '{old}' should {'be' if alias_in_all else 'NOT be'} in " + f"diff_diff.__all__ at status '{status}'" + ) + + elif kind == "default-flip": + expected = row["old_default"] if status in ("planned", "evaluate") else row["new_default"] + actual = _signature_default(old, rid) + assert ( + actual == expected + ), f"{rid}: {old!r} default is {actual}, expected {expected} at status '{status}'" + + elif kind == "env-default": + monkeypatch.delenv(row["env_var"], raising=False) + m = _LOCATOR_RE.match(old) + mod = _import_module_hard(m.group("mod"), rid) + resolver = getattr(mod, m.group("attrs"), None) + assert resolver is not None, f"{rid}: resolver {old!r} does not resolve" + result = bool(resolver()) + if status == "done": + # decided_default records the go/no-go outcome ("on" = flipped, "off" = + # evaluated and kept off - both are first-class terminal states). + expected = row.get("decided_default") == "on" + else: + expected = False + assert result is expected, ( + f"{rid}: {old!r}() with {row['env_var']} unset returned {result}, expected {expected} " + f"at status '{status}' (decided_default={row.get('decided_default')!r})" + ) + + elif kind == "warning-retirement": + text = (REPO_ROOT / row["code_refs"][0]).read_text() + present = row["snippet"] in text + if status in ("planned", "evaluate"): + assert present, ( + f"{rid}: snippet {row['snippet']!r} not found in {row['code_refs'][0]} - if the " + "message was reworded, update this row's snippet in the same PR" + ) + else: + assert not present, f"{rid} done but snippet still present in {row['code_refs'][0]}" + + +def _resolve_alias_target(locator, rid): + m = _LOCATOR_RE.match(locator) + if m is None or not m.group("attrs") or m.group("param"): + pytest.fail(f"{rid}: alias target {locator!r} must be 'module:Name'") + mod = _import_module_hard(m.group("mod"), rid) + obj = mod + for attr in m.group("attrs").split("."): + obj = getattr(obj, attr, None) + if obj is None: + pytest.fail(f"{rid}: alias target {locator!r} does not resolve") + return obj + + +# --------------------------------------------------------------------------- +# Scanner/validator self-tests (committed negative coverage - a regression in +# the scanner must not silently green the whole matrix) +# --------------------------------------------------------------------------- +_BASE_ROW = ( + "rows:\n" + " - id: M-900\n" + " kind: param\n" + " group: fixture\n" + ' old: "diff_diff:DifferenceInDifferences.fit[time]"\n' + " new: null\n" + ' deprecated_in: "3.9"\n' + ' removed_in: "4.0"\n' + " status: planned\n" + " phase: 2\n" + " code_refs: [diff_diff/estimators.py]\n" +) + + +def _schema_errors_for(text): + rows, parse_errors = parse_matrix(text) + return parse_errors + validate_schema(rows) + + +@pytest.mark.parametrize( + ("mutation", "expected_substring"), + [ + (lambda t: t.replace(" kind: param\n", " kindd: param\n"), "unknown field"), + (lambda t: t + _BASE_ROW.split("rows:\n")[1], "duplicate id"), + (lambda t: t.replace(" status: planned\n", " status: done\n"), "illegal for kind"), + (lambda t: t.replace(" status: planned\n", ""), "missing required field"), + (lambda t: t.replace('"4.0"', '"four"'), "does not match"), + ( + lambda t: t.replace(" status: planned\n", " status: shimmed\n"), + "requires a test_ref", + ), + ( + lambda t: t.replace(" kind: param\n", " kind: class\n").replace( + '"diff_diff:DifferenceInDifferences.fit[time]"', '"diff_diff.estimators:Foo"' + ), + "top-level 'diff_diff:' locator", + ), + ( + lambda t: t.replace(" code_refs: [diff_diff/estimators.py]\n", ""), + "code_refs must be a non-empty list", + ), + (lambda t: t.replace(" group: fixture\n", ""), "missing required field"), + ( + lambda t: t.replace(" kind: param\n", " kind: param-value\n").replace( + " status: planned\n", " status: shimmed\n" + ), + "requires a test_ref", + ), + ( + lambda t: t.replace(" kind: param\n", " kind: behavior\n").replace( + " status: planned\n", " status: done\n" + ), + "ledger-linked behavioral evidence", + ), + ], + ids=[ + "unknown-field", + "duplicate-id", + "status-illegal-for-kind", + "missing-required", + "bad-version", + "shimmed-without-test-ref", + "dotted-locator-on-removable", + "empty-code-refs", + "missing-group", + "param-value-shimmed-without-test-ref", + "behavior-done-without-test-ref", + ], +) +def test_schema_rejects_bad_rows(mutation, expected_substring): + errors = _schema_errors_for(mutation(_BASE_ROW)) + assert any( + expected_substring in e for e in errors + ), f"expected a schema error containing {expected_substring!r}, got: {errors}" + + +def test_base_fixture_row_is_clean(): + """The mutation baseline itself must be schema-clean, or the negative tests prove nothing.""" + assert _schema_errors_for(_BASE_ROW) == [] + + +def test_dual_parse_against_real_yaml_when_available(): + """Dev-only: wherever PyYAML happens to be installed, the purpose-built scanner must agree + with a real YAML load (CI does not install PyYAML - skipped there by design).""" + yaml = pytest.importorskip("yaml") + loaded = yaml.safe_load(MATRIX.read_text()) + assert isinstance(loaded, dict) and "rows" in loaded, "ledger is not valid YAML" + real_ids = [r["id"] for r in loaded["rows"]] + assert real_ids == _ROW_IDS, "scanner row ids diverge from yaml.safe_load - format drift"