diff --git a/CHANGELOG.md b/CHANGELOG.md index 310a0550..b13d0e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **4.0 migration guide** (`docs/migration-4.0.md`, v4 program Phase 4; the + section-9 checklist item 3 obligation): every change that requires action at + the 4.0 cut, as a 21-row orientation table plus a 108-row per-symbol appendix + carrying the ledger's own `Old`/`New` locators and a one-line fix for each. + Also worked before/after examples for the three 3.9 merges, a codemod regex + table for the mechanical renames, and a separate "already shipped in 3.9" + section for the behaviour changes that are easy to mistake for 4.0 work. The + appendix is checked against `docs/v4-deprecations.yaml` in both directions by + `tests/test_v4_matrix.py`, so a rescheduled or added ledger row fails CI until + the guide is updated. Two hazards the guide calls out explicitly because they + are not derivable from the ledger: `robust=` translates to `vcov_type=` + differently on each of its four estimators (on `TripleDifference`, + `vcov_type="classical"` raises), and post-fit `aggregate()` still raises on + bootstrapped fits for five estimator families whose fit-time keyword 4.0 + removes. +- **R-equivalents argument mapping table** (`docs/r_comparison.rst`, the + section-8 rule-8 obligation): an explicit `yname`/`tname`/`idname`/`gname` -> + `outcome`/`time`/`unit`/`first_treat` table, extended across the `did`, + `HonestDiD` and `synthdid` mappings the page already evidences with paired + code blocks, including the non-1:1 cases (`aggte(type="dynamic")` -> + `aggregate(type="event_study")`; HonestDiD's coefficient/vcov inputs carried + by the results object). This table is the library's stated alternative to + shipping R-spelling parameter aliases, and is now gated by + `tests/test_docs_ia.py`. + +### Fixed +- **`docs/r_comparison.rst` migration tips named a nonexistent results field** + (`.ci`); the canonical accessor is `.conf_int`. The `aggte()` comparison + comment also claimed aggregation is requested at fit time, which stopped being + the whole story when post-fit `results.aggregate()` shipped. + ### Added - **ChangesInChanges serves both 2x2 distributional estimators** (v4 program Phase 3(c); ledger rows [M-015] shimmed, [M-143]): diff --git a/DEFERRED.md b/DEFERRED.md index 8223aa15..10f7a2e6 100644 --- a/DEFERRED.md +++ b/DEFERRED.md @@ -127,6 +127,7 @@ decisions (refactor waivers, perf trade-offs, test-infrastructure calls) are rec | Decision | Location | Verified | |----------|----------|----------| +| **The 4.0 migration guide's code blocks are not snippet-executed.** `tests/test_doc_snippets.py` discovers a hardcoded list of `.rst` files and only `.. code-block:: python` / RST `::` bodies, so `docs/migration-4.0.md` gets no coverage. Deliberate: the guide is a MIXED document - most "after" examples (the renames, `results.att`) run on the current release, but the `field-flip` and `df-convention-flip` examples describe 4.0 behaviour that cannot run until 4.0, so a blanket execution lane would fail by construction. Closing the gap fully means a markdown-fence extractor plus a skip-marker convention for the future-API blocks - a harness change, out of scope for a docs PR. What IS gated: the appendix's ledger parity (`test_migration_guide_*`), which pins the row set and every mechanically checkable cell; and, since the first local review found all three merge examples carrying invalid keywords, `test_migration_guide_examples_bind_to_real_signatures`, which ast-parses the guide's python blocks and asserts every constructor/`fit()` keyword exists on the target signature. That is signature binding, NOT execution - it deliberately skips calls whose owner it cannot resolve (e.g. `results.aggregate(...)`), and it cannot catch a wrong VALUE or a wrong sequence of calls. The hand-written `Fix` prose remains unverifiable by any available means. | `docs/migration-4.0.md`, `tests/test_doc_snippets.py` | Phase 4 / 2026-08-09 | | **MultiPeriodDiD deprecation shim loses static constructor-arg checking (3.9 window).** The M-010 shim is `__init__(*args, **kwargs)` + an import-time `__signature__` mirror of DiD's constructor: runtime introspection (get_params/set_params, `inspect.signature`) and eager validation are fully preserved, but static type checkers / IDEs cannot check constructor arguments for the deprecated class until its 4.0 removal. Accepted: the alternative (hand-mirroring ~20 parameters) is a drift magnet on a class with one minor version of remaining life. | `diff_diff/estimators.py` | 3(a) / 2026-08-07 | | **DCDH `sklearn.base.clone` param-identity failure won't-fix.** `ChaisemartinDHaultfoeuille._validate_paths_of_interest` unconditionally canonicalizes `paths_of_interest` into a fresh `List[Tuple[int, ...]]`, so sklearn `clone()`'s post-construction `param1 is param2` identity check fails for configured instances - a pre-existing normalization the BaseEstimator mixin PR documented rather than changed (get_params/set_params signatures are clone-compatible; the dependency-free `cls(**est.get_params())` config-equality contract is the enforced one, `tests/test_base_estimator.py`). Fixing would mean returning the caller's raw object from a validator whose job is canonicalization. | `chaisemartin_dhaultfoeuille.py` | mixin PR / 2026-08-01 | | **scikit-learn stays out of dev deps; clone-identity tests remain importorskip-only.** The sklearn-`clone()` round-trip tests (`test_base_estimator.py`, had/rdd/cic suites) run only where scikit-learn happens to be installed - deliberate, matching the numpy/pandas/scipy-only dependency posture; the always-running contract is the dependency-free re-instantiation config-equality test. | `tests/test_base_estimator.py` | mixin PR / 2026-08-01 | diff --git a/TODO.md b/TODO.md index 1232dcd8..c79db7b1 100644 --- a/TODO.md +++ b/TODO.md @@ -78,7 +78,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m |-------|----------|--------|--------|----------| | Committed `fixest::feols` event-study golden for TWFE `event_study=True` (within + pooled specs, unbalanced + covariate panels, matched CR1 cluster convention, per-period effects + vcov block) - the in-suite gates are shared-core cross-checks (TWFE-within == MPD-absorb, pooled == MPD bit-exact), so a defect common to the shared core would pass; the live-R harness (`benchmarks/R/benchmark_multiperiod.R`, `feols(y ~ treated * time_f \| unit)`) validated the within design in `docs/benchmarks.rst` but is not a committed regression test - follow the `fixest_did_twfe_golden.json` committed-golden pattern (pytest.skip when absent) | `tests/test_fixest_did_twfe_parity.py`, `benchmarks/R/` | 3(a) R2 | Mid | Medium | | Type-blind `n_bootstrap` acceptance in already-validated estimators - HAD bool (`isinstance(..., int)` passes `True`, runs as 1 replicate), dCDH bool+float (its bare `< 0` check passes both `True` and `2.5`), TROP float (`2.5` passes the `>= 2` floor), SyntheticDiD float under all three variance methods + bool/negative under jackknife (its floor check is skipped there) - align these local checks with the `utils.validate_n_bootstrap` type guard (M-081 kept them out of the sweep: it scoped to previously-UNvalidated estimators only) | `diff_diff/had.py`, `diff_diff/chaisemartin_dhaultfoeuille.py`, `diff_diff/trop.py`, `diff_diff/synthetic_did.py` | 2(d) PR-B | Quick | Low | -| M-020-era CS fit-time `aggregate=` teachings persist in troubleshooting.rst (:215/:241/:244) and choosing_estimator.rst (:243) - CS examples still fit with the deprecated kwarg; migrate to post-fit `results.aggregate('event_study')` (the two HAD examples in the same file were migrated with M-027) | `docs/troubleshooting.rst` | 2(b) PR-4 | Quick | Low | +| Fit-time `aggregate=` teachings persist across the docs and tutorials (M-020 family, removed at 4.0); migrate to post-fit `results.aggregate(...)`. Re-scoped 2026-08-09 while shipping the migration guide - this is NOT a quick sweep. Narrative docs: `troubleshooting.rst:216`/`:245` both sit on the `n_bootstrap=999` fit at `:213`, and post-fit event-study aggregation **raises `NotImplementedError` on a bootstrapped fit** (`staggered_results.py:323`), so those two need a decision about what to teach before any edit; `choosing_estimator.rst:252` and `python_comparison.rst:416` use the analytical default and can migrate freely; `r_comparison.rst:119-127` needs its `results.event_study_effects`/`.group_effects` reads rebound to the `aggregate()` return values in the same edit (the fields stay `None` after post-fit aggregation). API pages: `docs/api/triple_diff.rst:56`, plus `business_report.rst:77` and `diagnostic_report.rst:59`, which **cannot** migrate today because both report consumers read the raw `event_study_effects` field; `had.rst:164` and `continuous_did.rst:137` are prose references only. Tutorials: **28 executable code-cell sites across 9 notebooks** (`02_staggered_did` 7, `09_real_world_examples` 6, `16_survey_did` 6, `26_composition_drift_calibration` 3, `21_had_pretest_workflow` 2, and one each in `08_triple_diff`, `16_wooldridge_etwfe`, `17_brand_awareness_survey`, `24_staggered_vs_collapsed_power`) - all nbmake-executed; `14_continuous_did` and `15_efficient_did` match only in markdown prose | `docs/troubleshooting.rst`, `docs/choosing_estimator.rst`, `docs/python_comparison.rst`, `docs/r_comparison.rst`, `docs/api/*.rst`, `docs/tutorials/*.ipynb` | 2(b) PR-4 | Heavy | Medium | | Evaluate adding the `BaseEstimator` param surface (get_params/set_params) to the exported classes that never had it - `PowerAnalysis`, `LinearRegression`, `BusinessReport`, `DiagnosticReport`, `TWFEWeightsResult` (a NEW public surface, deliberately out of the 2(c)-i pure-refactor scope; `LinearRegression` is the one `fit`-bearing class excluded from the contract suite's roster-completeness test). | `diff_diff/linalg.py`, `diff_diff/power.py` | mixin PR | Mid | Low | | Tighten the mypy suppressions that back the enforced-zero posture: burn down `prep_dgp`'s per-module `[index]` override (needs a None-vs-array restructure that preserves the seeded RNG stream), and evaluate re-enabling the globally disabled codes (`arg-type`, `return-value`, `var-annotated`, `assignment`) one at a time — `assignment` alone hid several real annotation drifts found during the 2026-07 triage. | `pyproject.toml` `[tool.mypy]`, `diff_diff/prep_dgp.py` | lint-CI | Mid | Low | | MMM interop follow-up: Meridian `roi_calibration_period` mask builder - accept the MMM's time index + channel order and emit the boolean `(n_media_times, n_media_channels)` mask so `.to_code()` scopes the prior to the experiment window automatically (today the caller passes a mask expression / `full_model_window=True`). | `diff_diff/mmm.py` | mmm-interop | Quick | Low | diff --git a/docs/conf.py b/docs/conf.py index 0342ff03..9001dcd5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,20 +34,20 @@ "sphinx_design", ] -# MyST renders the two in-site methodology markdown pages (REGISTRY.md, -# REPORTING.md) so cross-refs use :doc: instead of off-site blob/main URLs -# (stable-docs readers otherwise land on a different revision than their -# package version). dollarmath/amsmath cover the registry's LaTeX; -# heading anchors to depth 4 make its GitHub-style #section links resolve. +# MyST renders the three in-site markdown pages (methodology/REGISTRY.md, +# methodology/REPORTING.md, migration-4.0.md) so cross-refs use :doc: instead +# of off-site blob/main URLs (stable-docs readers otherwise land on a different +# revision than their package version). dollarmath/amsmath cover the registry's +# LaTeX; heading anchors to depth 4 make its GitHub-style #section links resolve. myst_enable_extensions = ["dollarmath", "amsmath"] myst_heading_anchors = 4 templates_path = ["_templates"] -# Only the two methodology pages are published; every other repo-internal -# markdown under docs/ stays out of the build (performance/benchmark notes -# are deliberately NOT on RTD — see the repo convention — and un-toctree'd -# .md files would fail the -W build as orphans). +# Only the two methodology pages and the 4.0 migration guide are published; +# every other repo-internal markdown under docs/ stays out of the build +# (performance/benchmark notes are deliberately NOT on RTD — see the repo +# convention — and un-toctree'd .md files would fail the -W build as orphans). exclude_patterns = [ "_build", "Thumbs.db", diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index e3b92457..7ae7cc00 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -139,6 +139,8 @@ sources: - path: diff_diff/guides/llms-practitioner.txt type: user_guide note: "Step-5 inference decision rule and small-G advice strings track the DiD/TWFE wild-bootstrap contract (M-096)" + - path: docs/migration-4.0.md + type: user_guide diff_diff/twfe.py: drift_risk: low @@ -166,6 +168,8 @@ sources: - path: diff_diff/guides/llms-full.txt type: user_guide note: "TWFE block documents the full fit signature incl. event-study params; the Conley section's panel examples use post=/event_study (rows M-010/M-082)" + - path: docs/migration-4.0.md + type: user_guide # ── CallawaySantAnna (staggered group) ─────��──────────────────────── @@ -209,6 +213,8 @@ sources: - path: docs/practitioner_decision_tree.rst section: "Staggered Rollout" type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── StaggeredTripleDifference (staggered_triple_diff group) ───────── @@ -240,6 +246,8 @@ sources: type: user_guide - path: docs/methodology/papers/ortiz-villavicencio-santanna-2025-review.md type: methodology + - path: docs/migration-4.0.md + type: user_guide # ── SunAbraham ────────────────────────────────────────────────────── @@ -272,6 +280,8 @@ sources: type: user_guide - path: docs/benchmarks.rst type: performance + - path: docs/migration-4.0.md + type: user_guide # ── ImputationDiD (imputation group) ───��──────────────────────────── @@ -305,6 +315,8 @@ sources: type: user_guide - path: docs/choosing_estimator.rst type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── TwoStageDiD (two_stage group) ────────────���───────────────────── @@ -336,6 +348,8 @@ sources: type: user_guide - path: docs/choosing_estimator.rst type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── SpilloverDiD (spillover group) ────────────────────────────────── @@ -388,6 +402,8 @@ sources: type: user_guide - path: docs/benchmarks.rst type: performance + - path: docs/migration-4.0.md + type: user_guide # ── ChaisemartinDHaultfoeuille (chaisemartin_dhaultfoeuille group) ── @@ -421,6 +437,8 @@ sources: - path: ROADMAP.md section: "de Chaisemartin-D'Haultfœuille (dCDH) Estimator" type: roadmap + - path: docs/migration-4.0.md + type: user_guide # ── ContinuousDiD (continuous_did group) ────────────────────��─────── @@ -453,6 +471,8 @@ sources: - path: docs/practitioner_decision_tree.rst section: "Varying Spending Levels" type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── HeterogeneousAdoptionDiD (HAD) ────────────────────────────────── @@ -482,6 +502,8 @@ sources: - path: docs/choosing_estimator.rst section: "SE methodology + Survey Design Support tables" type: user_guide + - path: docs/migration-4.0.md + type: user_guide diff_diff/rdplot.py: drift_risk: medium docs: @@ -502,6 +524,8 @@ sources: - path: diff_diff/guides/llms-full.txt section: "RDPlot" type: user_guide + - path: docs/migration-4.0.md + type: user_guide diff_diff/_rdrobust_port.py: drift_risk: high docs: @@ -540,6 +564,8 @@ sources: - path: docs/tutorials/22_had_survey_design.ipynb type: tutorial note: "Survey-aware HAD walkthrough; drift-locked at `tests/test_t22_had_survey_design_drift.py`. Drift-locks `HAD(design=\"auto\")` resolution to `continuous_near_d_lower` on T22's panel and the `survey_design=` path's SE/CI behavior." + - path: docs/migration-4.0.md + type: user_guide diff_diff/had_pretests.py: drift_risk: medium @@ -562,6 +588,8 @@ sources: - path: docs/tutorials/22_had_survey_design.ipynb type: tutorial note: "Survey-aware pretest workflow walkthrough (overall + event-study under SurveyDesign(strata=...)); drift-locks `_QUG_DEFERRED_SUFFIX`, the event-study summary QUG-skip note, and joint pretrends/homogeneity horizon labels under stratified-clustered Stute bootstrap (PR #432). Drift-locked at tests/test_t22_had_survey_design_drift.py" + - path: docs/migration-4.0.md + type: user_guide diff_diff/local_linear.py: drift_risk: low @@ -580,6 +608,8 @@ sources: - path: diff_diff/guides/llms-autonomous.txt section: "agent_workflow recommended starting call" type: user_guide + - path: docs/migration-4.0.md + type: user_guide diff_diff/profile.py: drift_risk: low @@ -620,6 +650,8 @@ sources: type: user_guide - path: docs/benchmarks.rst type: performance + - path: docs/migration-4.0.md + type: user_guide # ── TripleDifference ─────────────────────────────────────────────── @@ -646,6 +678,8 @@ sources: type: user_guide - path: docs/choosing_estimator.rst type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── StackedDiD (stacked_did group) ───────────────────────────────── @@ -684,6 +718,8 @@ sources: type: user_guide - path: docs/choosing_estimator.rst type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── WooldridgeDiD (wooldridge group) ──────────────────────────────── @@ -713,6 +749,8 @@ sources: type: user_guide - path: docs/choosing_estimator.rst type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── LPDiD (lpdid group) ──────────────────────────────────────────── @@ -744,6 +782,8 @@ sources: type: user_guide - path: docs/survey-roadmap.md type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── ChangesInChanges + QDiD (changes_in_changes group) ──────────── @@ -778,6 +818,8 @@ sources: type: user_guide - path: docs/r_comparison.rst type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── TROP (trop group) ────────────────────────────────────────────── @@ -806,6 +848,8 @@ sources: type: user_guide - path: docs/performance-plan.md type: performance + - path: docs/migration-4.0.md + type: user_guide # ── SyntheticControl ──────────────────────────────────────────────── @@ -834,6 +878,8 @@ sources: type: user_guide - path: docs/tutorials/25_synthetic_control_policy.ipynb type: tutorial + - path: docs/migration-4.0.md + type: user_guide diff_diff/synthetic_control_results.py: drift_risk: low @@ -889,6 +935,10 @@ sources: - path: diff_diff/guides/llms.txt section: "Diagnostics and Sensitivity Analysis" type: user_guide + - path: docs/migration-4.0.md + type: user_guide + - path: docs/r_comparison.rst + type: user_guide # ── BaconDecomposition ───���───────────────────────────────────────── @@ -914,6 +964,8 @@ sources: - path: diff_diff/guides/llms.txt section: "Estimators" type: user_guide + - path: docs/migration-4.0.md + type: user_guide # ── Diagnostics & analysis ───────���───────────────────────────────── @@ -930,6 +982,8 @@ sources: type: api_reference - path: docs/tutorials/04_parallel_trends.ipynb type: tutorial + - path: docs/migration-4.0.md + type: user_guide diff_diff/pretrends.py: drift_risk: low @@ -943,6 +997,8 @@ sources: type: tutorial - path: docs/tutorials/07_pretrends_power.ipynb type: tutorial + - path: docs/migration-4.0.md + type: user_guide diff_diff/business_report.py: drift_risk: medium @@ -960,6 +1016,8 @@ sources: - path: diff_diff/guides/llms-full.txt section: "BusinessReport" type: user_guide + - path: docs/migration-4.0.md + type: user_guide diff_diff/diagnostic_report.py: drift_risk: medium @@ -977,6 +1035,8 @@ sources: - path: diff_diff/guides/llms-full.txt section: "DiagnosticReport" type: user_guide + - path: docs/migration-4.0.md + type: user_guide diff_diff/mmm.py: drift_risk: medium @@ -1011,6 +1071,8 @@ sources: type: tutorial - path: docs/tutorials/07_pretrends_power.ipynb type: tutorial + - path: docs/migration-4.0.md + type: user_guide # ── Survey support ───────────────────────────────────────────────── @@ -1069,6 +1131,8 @@ sources: type: performance - path: docs/api/utils.rst type: api_reference + - path: docs/migration-4.0.md + type: user_guide diff_diff/conley.py: drift_risk: medium @@ -1120,6 +1184,10 @@ sources: section: "SyntheticDiD" type: methodology note: "SyntheticDiDResults hosts validation diagnostics (LOO, weight concentration, in-time placebo, zeta sensitivity)" + - path: docs/migration-4.0.md + type: user_guide + - path: docs/r_comparison.rst + type: user_guide diff_diff/aggregation.py: drift_risk: low @@ -1130,6 +1198,10 @@ sources: section: "6. Aggregation contract" type: design_spec note: "AggregationResult / AggregationKit / AggregationMixin - the post-fit results.aggregate(type=) surface (4.0 program Phase 2; ledger rows M-020..M-027, M-122)" + - path: docs/migration-4.0.md + type: user_guide + - path: docs/r_comparison.rst + type: user_guide diff_diff/results_base.py: drift_risk: low @@ -1140,6 +1212,8 @@ sources: section: "5. Results contract" type: design_spec note: "BaseResults / Diagnostic marker / EventStudyResults contract (4.0 program Phase 2; ledger rows M-091/M-092/M-093)" + - path: docs/migration-4.0.md + type: user_guide diff_diff/bootstrap_utils.py: drift_risk: medium @@ -1175,6 +1249,8 @@ sources: type: user_guide - path: docs/survey-roadmap.md type: roadmap + - path: docs/migration-4.0.md + type: user_guide diff_diff/prep_dgp.py: drift_risk: low @@ -1187,6 +1263,8 @@ sources: type: user_guide - path: docs/tutorials/25_synthetic_control_policy.ipynb type: tutorial + - path: docs/migration-4.0.md + type: user_guide diff_diff/datasets.py: drift_risk: low @@ -1198,6 +1276,8 @@ sources: - path: docs/methodology/REGISTRY.md section: "Castle Doctrine treatment coding" type: methodology + - path: docs/migration-4.0.md + type: user_guide diff_diff/practitioner.py: drift_risk: low @@ -1208,6 +1288,8 @@ sources: section: "Practitioner Workflow" type: user_guide note: "HAD handlers (_handle_had / _handle_had_event_study) emit did_had_pretest_workflow + bandwidth_diagnostics references; symmetric Step-4 routing in _handle_continuous; _handle_cic branches on ChangesInChangesResults.method/covariates and restates the CiC/QDiD fit-time diagnostics (interior range, envelope, footnote-21, bootstrap health)" + - path: docs/migration-4.0.md + type: user_guide # ── Visualization (visualization group) ──────────────────────────── diff --git a/docs/index.rst b/docs/index.rst index 82ffa26f..303ac99e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -85,8 +85,8 @@ Explore the Documentation :link: user_guide :link-type: doc - References, R and Python comparisons, benchmarks, and the - methodology registry. + References, R and Python comparisons, benchmarks, the 4.0 migration + guide, and the methodology registry. .. grid-item-card:: API Reference :link: api/index diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md new file mode 100644 index 00000000..d855bdd7 --- /dev/null +++ b/docs/migration-4.0.md @@ -0,0 +1,384 @@ +# Migrating to 4.0 + +```{note} +This describes the **upcoming 4.0 release**. Most of the renames below can be adopted from +3.9 onward, well before 4.0 lands — the new spellings and the old ones both work during the +deprecation window, so you can migrate incrementally and silence the warnings as you go. +``` + +diff-diff 4.0 removes the deprecated surface that 3.9 shipped warnings for, merges three pairs +of estimators, and flips two families of inference defaults. Every change is tracked as a row in +`docs/v4-deprecations.yaml`, and the appendix below is checked against that ledger by +`tests/test_v4_matrix.py` — if a row is added, moved, or rescheduled and this page is not +updated, CI fails. + +## What changes + +| Area | What changes | Rows | Where | +|---|---|---|---| +| aggregate-postfit | Aggregation moves off `fit()` onto post-fit `results.aggregate(...)` | 13 | §4 | +| alias-table | Six export aliases are removed in favour of their canonical class names | 6 | §7 | +| constructor-hygiene | `covariates=` moves from the constructor to `fit()` | 1 | §7b | +| df-convention-flip | The `df_convention` default flips to `"cluster"` on seven estimators - numbers move | 7 | §6 | +| diagnostic-family | Bacon is re-homed out of the estimator roster into the diagnostics family | 1 | §7b | +| field-flip | Nine results containers rename `overall_att` to the canonical `att` | 9 | §5 | +| function-wrappers | Eight module-level wrapper functions are removed; call the classes | 8 | §7 | +| merge-ddd | `StaggeredTripleDifference` is absorbed by `TripleDifference` | 5 | §2 | +| merge-mpd | `MultiPeriodDiD` is absorbed by `TwoWayFixedEffects(event_study=True)` | 5 | §2 | +| merge-qdid | `QDiD` is absorbed by `ChangesInChanges(method="qdid")` | 2 | §2 | +| obligation-sdid-params | Two SyntheticDiD constructor params (inert since 3.0.0) are removed | 3 | §7b | +| obligation-warning-retirements | A transition `FutureWarning` stops being emitted | 1 | §7b | +| policy-auto-cluster | TwoWayFixedEffects auto-clusters on `unit` unless you pass `cluster=` | 1 | §6 | +| renames-cohort | `cohort=` becomes `first_treat=` | 1 | §3 | +| renames-col-suffix | The `*_col` suffix is dropped across 27 parameters and one field | 27 | §3 | +| renames-control-group | `clean_control=` becomes `control_group=` | 2 | §3 | +| renames-dcdh | dCDH's `group=`/`controls=` become `unit=`/`covariates=` | 4 | §3 | +| renames-level | Aggregation-level params and their accepted values unify on `level=`/`"event_study"` | 4 | §3 | +| renames-post | The post-dummy `time=` becomes `post=` | 3 | §3 | +| renames-robust-drop | `robust=` is dropped in favour of `vcov_type=` - read §3, the translation is per-estimator | 4 | §3 | +| results-contract | A legacy sentinel field is retired in favour of the unified event-study surface | 1 | §5 | + +## The three merges + +Each merge keeps one class and retires the other. The retired class still works in 3.9 and warns. + +### MultiPeriodDiD → TwoWayFixedEffects(event_study=True) + +```python +# 3.x +from diff_diff import MultiPeriodDiD +results = MultiPeriodDiD().fit(data, outcome="y", unit="id", time="period", treatment="treat") + +# 4.0 +from diff_diff import TwoWayFixedEffects +results = TwoWayFixedEffects().fit( + data, outcome="y", unit="id", time="period", treatment="treat", + event_study=True, spec="pooled", post_periods=[3, 4, 5], +) +``` + +Two things are easy to get wrong here, and both change numbers: + +- **`spec="pooled"` reproduces the MultiPeriodDiD design.** The new default is `spec="within"`, + which adds unit fixed effects. That moves **point estimates as well as standard errors** on + unbalanced panels or with covariates — the two specs coincide only in the restricted + equivalence case (balanced panel, no covariates, simultaneous adoption). "Only SEs move" is + not the migration message. +- **`post_periods=` is required** in event-study mode. MultiPeriodDiD defaulted to a midpoint + split of the calendar, which is a silent guess; the merged mode makes you state the treatment + boundary. `spec="pooled"` is also the only spec valid for repeated cross-sections. + +### StaggeredTripleDifference → TripleDifference + +```python +# 3.x +from diff_diff import StaggeredTripleDifference +results = StaggeredTripleDifference().fit(data, outcome="y", unit="id", time="period", + first_treat="g", eligibility="p") + +# 4.0 +from diff_diff import TripleDifference +results = TripleDifference().fit(data, outcome="y", unit="id", time="period", + first_treat="g", partition="p") +``` + +The staggered fit parameters are keyword-only on the merged class. The results container is the +unified `TripleDifference` shape; the 2x2x2 design reads as a degenerate single-ATT view of it. + +### QDiD → ChangesInChanges(method="qdid") + +```python +# 3.x +from diff_diff import QDiD +results = QDiD(n_bootstrap=200, seed=42).fit(data, outcome="y", treatment="treat", time="post") + +# 4.0 +from diff_diff import ChangesInChanges +results = ChangesInChanges(method="qdid", n_bootstrap=200, seed=42).fit( + data, outcome="y", treatment="treat", time="post") +``` + +**Only the class spelling is deprecated, not the estimator.** `method="qdid"` is a fully +supported comparison mode and emits no warning of its own — the numbers are unchanged, because +it is the same engine. `method="cic"` is the default and encodes Athey–Imbens' recommendation. + +## Renamed parameters + +Most renames are mechanical: the new spelling already exists, so you can adopt it today and the +old one keeps working until 4.0. See the appendix for the full list, and the +[R Comparison](r_comparison.rst) page for how these names map onto the R packages. + +Three families need more than a search-and-replace: + +**`robust=` → `vcov_type=` is per-estimator, not a single rule.** Verified against the current +release: + +| estimator | `robust=True` | `robust=False` | +|---|---|---| +| `DifferenceInDifferences`, `LinearRegression` | already the `hc1` default — drop it | `vcov_type="classical"` | +| `TripleDifference` | drop it | drop it — only `hc1` is accepted, so `robust=` never changed inference, and `vcov_type="classical"` **raises** | +| `HeterogeneousAdoptionDiD` | `vcov_type="hc1"` | drop it — non-robust is its legacy default | + +**`time=` → `post=` carries a semantic change, and the old name survives.** On +`DifferenceInDifferences`, `TripleDifference` and `TwoWayFixedEffects`, `time=` used to mean a +0/1 post dummy. The name is not removed — it is **repurposed** to mean the calendar column. From +4.0, passing `time=` in static mode (`TwoWayFixedEffects`) or 2x2x2 mode (`TripleDifference`) +**raises `ValueError`** rather than being silently reinterpreted. If you pass a post dummy, rename +it to `post=`; if you pass a calendar column to an event-study or staggered fit, leave it alone. + +**Dropped parameters are not always deletions.** A parameter with no successor in the appendix +may still need a replacement call — see its `Fix` cell. + +## Post-fit aggregation + +Aggregation moves off `fit()` onto the results object: + +```python +# 3.x +results = CallawaySantAnna().fit(data, ..., aggregate="event_study") + +# 4.0 +results = CallawaySantAnna().fit(data, ...) +event_study = results.aggregate("event_study") +``` + +```{warning} +**Bootstrapped fits have no route yet.** On `CallawaySantAnna`, `ImputationDiD`, `TwoStageDiD`, +`EfficientDiD` and `ContinuousDiD`, the post-fit recompute levels raise `NotImplementedError` +when the fit used `n_bootstrap > 0`, while the fit-time keyword they replace is removed at 4.0. +If you bootstrap *and* aggregate, keep the fit-time call for now and track the open +`TODO.md` draw-retention rows. `aggregate("simple")` does relay, and `StackedDiD`, +`ChaisemartinDHaultfoeuille` and `HeterogeneousAdoptionDiD` are unaffected — their `aggregate()` +is a pure view over stored fields. +``` + +## Results fields + +Nine containers rename `overall_att` to the canonical `att`. **The new accessor already works +today**, so this migration can be done immediately; the old name becomes a warning-emitting +property at 4.0 and is removed at 5.0. + +```python +att = results.att # canonical, works now +att = results.overall_att # warns from 4.0, removed at 5.0 +``` + +**The whole inference quintet moves, not just the point estimate.** Wherever a container +carried `overall_*` inference fields alongside `overall_att`, they flip to the canonical +names in the same step: + +| old | new | +|---|---| +| `overall_att` | `att` | +| `overall_se` | `se` | +| `overall_t_stat` | `t_stat` | +| `overall_p_value` | `p_value` | +| `overall_conf_int` | `conf_int` | + +```{note} +`ContinuousDiDResults` is the outlier: its sibling fields are spelled +`overall_att_se`, `overall_att_t_stat`, `overall_att_p_value` and +`overall_att_conf_int` — with the `att` infix — and they flip to the same canonical +names. If you grep for `overall_se` you will miss them. +``` + +## Inference defaults that move numbers + +Two changes alter results without changing any call: + +- **`df_convention` flips to `"cluster"`** on seven estimators. Pass `df_convention="residual"` + to reproduce 3.x numbers exactly. +- **Panel estimators auto-cluster at `unit`** when `cluster=` is omitted. Pass + **`cluster=False`** to disable it — `None` and omission both mean "auto-cluster", so + `cluster=None` does *not* reproduce unclustered 3.x standard errors. Cross-sectional + 2x2 estimators stay HC-robust unless you pass `cluster=`. Note the TWFE **event-study** + mode has auto-clustered since 3.9 (it shipped that way with the merge), so this flip + changes the static and other panel paths, not that one. + +## Removed functions and aliases + +Eight module-level wrapper functions and six export aliases are removed. Both were thin +indirections: call the estimator class directly, or import the canonical name. The appendix lists +each one with its target. + +## Remaining 4.0 changes + +Smaller items that do not fit the families above — two inert `SyntheticDiD` constructor +parameters, the `covariates=` constructor-to-`fit()` move, a retired transition warning, and the +Bacon roster re-homing. See the appendix. + +One pending decision: the `DIFF_DIFF_SOLVE_OLS_FASTPATH` environment default has a go/no-go due +at 4.0 that has not been made. If it lands on, it is a numerics change and will be documented +then; "evaluated, kept off" is an equally valid outcome, so it carries no appendix row today. + +## Codemod + +The mechanical keyword renames can be applied with a regex sweep. This table covers only +**identifier** renames — it deliberately excludes dropped parameters (no successor to write), +results-field renames (`.groups` → `.units` would rewrite every pandas `groupby(...).groups` in +your code), and accepted-value renames, which change strings rather than names. + +| find | replace | +|---|---| +| `\boutcome_col\s*=` | `outcome=` | +| `\bdose_col\s*=` | `dose=` | +| `\btime_col\s*=` | `time=` | +| `\bunit_col\s*=` | `unit=` | +| `\bfirst_treat_col\s*=` | `first_treat=` | +| `\brunning_col\s*=` | `running=` | +| `\btreatment_col\s*=` | `takeup=` | +| `\bweight_col\s*=` | `weights=` | +| `\bcohort\s*=` | `first_treat=` | +| `\bclean_control\s*=` | `control_group=` | +| `\bcontrols\s*=` | `covariates=` | +| `\baggregation\s*=` | `level=` | + +Note `treatment_col` maps to **`takeup`**, not `treatment` — it is the one `*_col` parameter +whose replacement is not just the suffix dropped. + +**Three renames are deliberately NOT in the table, because a global replace would corrupt +working code.** Each is a rename only in a specific call: + +| rename | where it applies | why it cannot be global | +|---|---|---| +| `group=` → `unit=` | `ChaisemartinDHaultfoeuille.fit`, `twowayfeweights` | `TripleDifference.fit` takes a `group=` that is **not** renamed | +| `time=` → `post=` | `DifferenceInDifferences.fit`, `permutation_test`, `leave_one_out_test` | `time=` survives everywhere else as the calendar column — see the renamed-parameters section | +| `aggregate=` → post-fit call | the aggregation family | not a rename at all; it moves off `fit()` | + +Review every hit even in the safe table: these are word-boundary matches on common words. + +## Already shipped in 3.9 + +These landed in 3.9 and need no 4.0 action, but they moved behaviour and are easy to mistake for +4.0 changes: + +**Numbers moved.** The ETWFE reference-period fixes (unidentified-cohort exclusion, and a +fail-closed guard on designs with no estimable post-treatment cell), the clustered-CR1 +`K_reference` convergence, and the tail-df consolidation. + +**Fail-closed validation.** `n_bootstrap` semantics unified; `inference="wild_bootstrap"` without +`cluster=` now raises; `TripleDifference(pscore_trim=0)` is rejected; an all-NaN Wooldridge +overall ATT raises rather than returning NaN. These are refusals, not default changes — no +numeric default moved. + +**Additive API and container changes.** The unified `EventStudyResults` surface, the +`AggregationResult` container, and the `Diagnostic` marker base on the diagnostic result roster. +The alias-diet `__getattr__` shim also landed here: `CDiD`, `Stacked` and `Gardner` still import +but now emit a `FutureWarning`, and they no longer appear in `dir()`/`vars()`. + +## Appendix: every 4.0 change + +One row per ledger row that requires action at 4.0 — 108 in total. `Old` and `New` are the +ledger's own locators. An em dash in `New` means the ledger names no successor **locator**; it +does not mean no action is required, so read the `Fix` cell. + +| Row | Group | Old | New | Fix | +|---|---|---|---|---| +| M-020 | aggregate-postfit | `diff_diff:CallawaySantAnna.fit[aggregate]` | `diff_diff:CallawaySantAnnaResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-021 | aggregate-postfit | `diff_diff:ImputationDiD.fit[aggregate]` | `diff_diff:ImputationDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-022 | aggregate-postfit | `diff_diff:TwoStageDiD.fit[aggregate]` | `diff_diff:TwoStageDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-023 | aggregate-postfit | `diff_diff:EfficientDiD.fit[aggregate]` | `diff_diff:EfficientDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-024 | aggregate-postfit | `diff_diff:StackedDiD.fit[aggregate]` | `diff_diff:StackedDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. | +| M-025 | aggregate-postfit | `diff_diff:ContinuousDiD.fit[aggregate]` | `diff_diff:ContinuousDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-026 | aggregate-postfit | `diff_diff:ChaisemartinDHaultfoeuille.fit[aggregate]` | `diff_diff:ChaisemartinDHaultfoeuilleResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. | +| M-027 | aggregate-postfit | `diff_diff:HeterogeneousAdoptionDiD.fit[aggregate]` | `diff_diff:HeterogeneousAdoptionDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. | +| M-117 | aggregate-postfit | `diff_diff:CallawaySantAnna.fit[balance_e]` | `diff_diff:CallawaySantAnnaResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-118 | aggregate-postfit | `diff_diff:ImputationDiD.fit[balance_e]` | `diff_diff:ImputationDiDResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-119 | aggregate-postfit | `diff_diff:TwoStageDiD.fit[balance_e]` | `diff_diff:TwoStageDiDResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-120 | aggregate-postfit | `diff_diff:EfficientDiD.fit[balance_e]` | `diff_diff:EfficientDiDResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-139 | aggregate-postfit | `diff_diff:did_had_pretest_workflow[aggregate]` | — | Remove `aggregate=`; the battery is inferred from panel shape - two periods select the overall battery, more than two select the event-study battery. It is not selected post-fit (`HADPretestReport.aggregate` is a metadata field, not a method). | +| M-060 | alias-table | `EventStudy` | — | Import `TwoWayFixedEffects(...).fit(..., event_study=True)` - the alias is dropped, not retargeted instead of the `EventStudy` alias. | +| M-061 | alias-table | `QDiDResults` | — | Import `ChangesInChangesResults` instead of the `QDiDResults` alias. | +| M-064 | alias-table | `SDDD` | — | Import `TripleDifference` (staggered mode) instead of the `SDDD` alias. | +| M-132 | alias-table | `CDiD` | — | Import `ContinuousDiD` instead of the `CDiD` alias. | +| M-133 | alias-table | `Stacked` | — | Import `StackedDiD` instead of the `Stacked` alias. | +| M-134 | alias-table | `Gardner` | — | Import `TwoStageDiD` instead of the `Gardner` alias. | +| M-084 | constructor-hygiene | `diff_diff:ContinuousDiD[covariates]` | `diff_diff:ContinuousDiD.fit[covariates]` | `covariates=` moves from the constructor to `fit()` - pass it at fit time. | +| M-004 | df-convention-flip | `diff_diff:DifferenceInDifferences[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | +| M-005 | df-convention-flip | `diff_diff:TwoWayFixedEffects[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | +| M-006 | df-convention-flip | `diff_diff:LinearRegression[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | +| M-128 | df-convention-flip | `diff_diff:SunAbraham[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | +| M-129 | df-convention-flip | `diff_diff:WooldridgeDiD[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | +| M-130 | df-convention-flip | `diff_diff:StackedDiD[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | +| M-131 | df-convention-flip | `diff_diff:ImputationDiD[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | +| M-090 | diagnostic-family | `diff_diff:BaconDecomposition` | — | Bacon moves out of the estimator roster into the diagnostics family - update docs references and imports of the roster, not call sites. | +| M-050 | field-flip | `diff_diff:CallawaySantAnnaResults.overall_att` | `diff_diff:CallawaySantAnnaResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-051 | field-flip | `diff_diff:SunAbrahamResults.overall_att` | `diff_diff:SunAbrahamResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-052 | field-flip | `diff_diff:ImputationDiDResults.overall_att` | `diff_diff:ImputationDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-053 | field-flip | `diff_diff:TwoStageDiDResults.overall_att` | `diff_diff:TwoStageDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-054 | field-flip | `diff_diff:StackedDiDResults.overall_att` | `diff_diff:StackedDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-055 | field-flip | `diff_diff:EfficientDiDResults.overall_att` | `diff_diff:EfficientDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-056 | field-flip | `diff_diff:WooldridgeDiDResults.overall_att` | `diff_diff:WooldridgeDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-057 | field-flip | `diff_diff:ChaisemartinDHaultfoeuilleResults.overall_att` | `diff_diff:ChaisemartinDHaultfoeuilleResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-058 | field-flip | `diff_diff:ContinuousDiDResults.overall_att` | `diff_diff:ContinuousDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-070 | function-wrappers | `diff_diff:imputation_did` | — | Call the estimator class directly instead of `imputation_did`. | +| M-071 | function-wrappers | `diff_diff:two_stage_did` | — | Call the estimator class directly instead of `two_stage_did`. | +| M-072 | function-wrappers | `diff_diff:stacked_did` | — | Call the estimator class directly instead of `stacked_did`. | +| M-073 | function-wrappers | `diff_diff:trop` | — | Call the estimator class directly instead of `trop`. | +| M-074 | function-wrappers | `diff_diff:synthetic_control` | — | Call the estimator class directly instead of `synthetic_control`. | +| M-075 | function-wrappers | `diff_diff:triple_difference` | — | Call the estimator class directly instead of `triple_difference`. | +| M-076 | function-wrappers | `diff_diff:bacon_decompose` | — | Call the estimator class directly instead of `bacon_decompose`. | +| M-077 | function-wrappers | `diff_diff:chaisemartin_dhaultfoeuille` | — | Call the estimator class directly instead of `chaisemartin_dhaultfoeuille`. | +| M-013 | merge-ddd | `diff_diff:StaggeredTripleDifference` | `diff_diff:TripleDifference.fit[first_treat]` | See the merges section for the worked before/after. | +| M-014 | merge-ddd | `diff_diff:StaggeredTripleDiffResults` | — | Read the unified `TripleDifference` results shape (a degenerate single-ATT view for 2x2x2) instead of this container. | +| M-085 | merge-ddd | `diff_diff:TripleDifference.fit[time]` | — | In 2x2x2 mode, `time=` raises - pass `post=` for the 0/1 post dummy. `time=` now means the staggered calendar column only. | +| M-140 | merge-ddd | `diff_diff:TripleDifference.fit[aggregate]` | `diff_diff:TripleDifferenceResults.aggregate` *(not yet implemented)* | No successor yet - post-fit `aggregate()` is not implemented on the DDD container (open `TODO.md` row). Keep the fit-time call until it lands. | +| M-141 | merge-ddd | `diff_diff:TripleDifference.fit[balance_e]` | `diff_diff:TripleDifferenceResults.aggregate[balance_e]` *(not yet implemented)* | No successor yet - post-fit `aggregate()` is not implemented on the DDD container (open `TODO.md` row). Keep the fit-time call until it lands. | +| M-010 | merge-mpd | `diff_diff:MultiPeriodDiD` | `diff_diff:TwoWayFixedEffects.fit[event_study]` | See the merges section for the worked before/after. | +| M-011 | merge-mpd | `diff_diff:MultiPeriodDiDResults` | — | Read the unified event-study surface on the merged `TwoWayFixedEffects` results instead of this container. | +| M-012 | merge-mpd | `diff_diff:PeriodEffect` | — | Superseded by the unified event-study representation - read the event-study surface rather than per-period `PeriodEffect` objects. | +| M-016 | merge-mpd | `diff_diff:MultiPeriodDiDResults.period_effects` | — | Not removed at 4.0: it becomes a `FutureWarning` property view over the unified event-study surface on the successor container, and is removed at 5.0. Migrate the read to the successor class. | +| M-083 | merge-mpd | `diff_diff:TwoWayFixedEffects.fit[time]` | — | In static mode (`event_study=False`), `time=` raises - pass `post=` for the 0/1 post dummy. `time=` now means the calendar column only. | +| M-015 | merge-qdid | `diff_diff:QDiD` | `diff_diff:ChangesInChanges[method]` | See the merges section for the worked before/after. | +| M-143 | merge-qdid | `diff_diff:ChangesInChangesResults.estimator` | `diff_diff:ChangesInChangesResults.method` | See the merges section for the worked before/after. | +| M-001 | obligation-sdid-params | `diff_diff:SyntheticDiD[lambda_reg]` | — | Drop it. It has been IGNORED since 3.0.0, so 3.x already auto-computes regularization - do NOT copy its value into `zeta_omega=`, which activates an override that was inert and changes weights, ATT and inference. Set `zeta_omega=` only as a deliberate new choice. | +| M-002 | obligation-sdid-params | `diff_diff:SyntheticDiD[zeta]` | — | Drop it. It has been IGNORED since 3.0.0, so 3.x already auto-computes regularization - do NOT copy its value into `zeta_lambda=`, which activates an override that was inert and changes weights, ATT and inference. Set `zeta_lambda=` only as a deliberate new choice. | +| M-003 | obligation-sdid-params | `diff_diff:SyntheticDiDResults.placebo_effects` | `diff_diff:SyntheticDiDResults.variance_effects` | Read `variance_effects` instead of `placebo_effects`. | +| M-007 | obligation-warning-retirements | `diff_diff.estimators` | — | The MultiPeriodDiD `e=-1` transition FutureWarning stops being emitted; drop any warning filter that suppressed it. | +| M-080 | policy-auto-cluster | `diff_diff:TwoWayFixedEffects[cluster]` | — | Panel estimators auto-cluster at `unit` when `cluster=` is omitted; pass `cluster=False` to disable it and keep unclustered 3.x standard errors (`None`/omission means auto-cluster, not off). | +| M-032 | renames-cohort | `diff_diff:WooldridgeDiD.fit[cohort]` | `diff_diff:WooldridgeDiD.fit[first_treat]` | Rename the keyword: `cohort=` becomes `first_treat=`. | +| M-035 | renames-col-suffix | `diff_diff:HeterogeneousAdoptionDiD.fit[outcome_col]` | `diff_diff:HeterogeneousAdoptionDiD.fit[outcome]` | Rename the keyword: `outcome_col=` becomes `outcome=`. | +| M-036 | renames-col-suffix | `diff_diff:HeterogeneousAdoptionDiD.fit[dose_col]` | `diff_diff:HeterogeneousAdoptionDiD.fit[dose]` | Rename the keyword: `dose_col=` becomes `dose=`. | +| M-037 | renames-col-suffix | `diff_diff:HeterogeneousAdoptionDiD.fit[time_col]` | `diff_diff:HeterogeneousAdoptionDiD.fit[time]` | Rename the keyword: `time_col=` becomes `time=`. | +| M-038 | renames-col-suffix | `diff_diff:HeterogeneousAdoptionDiD.fit[unit_col]` | `diff_diff:HeterogeneousAdoptionDiD.fit[unit]` | Rename the keyword: `unit_col=` becomes `unit=`. | +| M-039 | renames-col-suffix | `diff_diff:HeterogeneousAdoptionDiD.fit[first_treat_col]` | `diff_diff:HeterogeneousAdoptionDiD.fit[first_treat]` | Rename the keyword: `first_treat_col=` becomes `first_treat=`. | +| M-040 | renames-col-suffix | `diff_diff:RegressionDiscontinuity.fit[outcome_col]` | `diff_diff:RegressionDiscontinuity.fit[outcome]` | Rename the keyword: `outcome_col=` becomes `outcome=`. | +| M-041 | renames-col-suffix | `diff_diff:RegressionDiscontinuity.fit[running_col]` | `diff_diff:RegressionDiscontinuity.fit[running]` | Rename the keyword: `running_col=` becomes `running=`. | +| M-042 | renames-col-suffix | `diff_diff:RegressionDiscontinuity.fit[treatment_col]` | `diff_diff:RegressionDiscontinuity.fit[takeup]` | Rename the keyword: `treatment_col=` becomes `takeup=`. | +| M-088 | renames-col-suffix | `diff_diff:RDPlot.fit[outcome_col]` | `diff_diff:RDPlot.fit[outcome]` | Rename the keyword: `outcome_col=` becomes `outcome=`. | +| M-089 | renames-col-suffix | `diff_diff:RDPlot.fit[running_col]` | `diff_diff:RDPlot.fit[running]` | Rename the keyword: `running_col=` becomes `running=`. | +| M-094 | renames-col-suffix | `diff_diff:RegressionDiscontinuityResults.treatment_col` | `diff_diff:RegressionDiscontinuityResults.takeup` | Read `takeup` instead of `treatment_col` on the results object. | +| M-098 | renames-col-suffix | `diff_diff:joint_pretrends_test[outcome_col]` | `diff_diff:joint_pretrends_test[outcome]` | Rename the keyword: `outcome_col=` becomes `outcome=`. | +| M-099 | renames-col-suffix | `diff_diff:joint_pretrends_test[dose_col]` | `diff_diff:joint_pretrends_test[dose]` | Rename the keyword: `dose_col=` becomes `dose=`. | +| M-100 | renames-col-suffix | `diff_diff:joint_pretrends_test[time_col]` | `diff_diff:joint_pretrends_test[time]` | Rename the keyword: `time_col=` becomes `time=`. | +| M-101 | renames-col-suffix | `diff_diff:joint_pretrends_test[unit_col]` | `diff_diff:joint_pretrends_test[unit]` | Rename the keyword: `unit_col=` becomes `unit=`. | +| M-102 | renames-col-suffix | `diff_diff:joint_pretrends_test[first_treat_col]` | `diff_diff:joint_pretrends_test[first_treat]` | Rename the keyword: `first_treat_col=` becomes `first_treat=`. | +| M-103 | renames-col-suffix | `diff_diff:joint_homogeneity_test[outcome_col]` | `diff_diff:joint_homogeneity_test[outcome]` | Rename the keyword: `outcome_col=` becomes `outcome=`. | +| M-104 | renames-col-suffix | `diff_diff:joint_homogeneity_test[dose_col]` | `diff_diff:joint_homogeneity_test[dose]` | Rename the keyword: `dose_col=` becomes `dose=`. | +| M-105 | renames-col-suffix | `diff_diff:joint_homogeneity_test[time_col]` | `diff_diff:joint_homogeneity_test[time]` | Rename the keyword: `time_col=` becomes `time=`. | +| M-106 | renames-col-suffix | `diff_diff:joint_homogeneity_test[unit_col]` | `diff_diff:joint_homogeneity_test[unit]` | Rename the keyword: `unit_col=` becomes `unit=`. | +| M-107 | renames-col-suffix | `diff_diff:joint_homogeneity_test[first_treat_col]` | `diff_diff:joint_homogeneity_test[first_treat]` | Rename the keyword: `first_treat_col=` becomes `first_treat=`. | +| M-108 | renames-col-suffix | `diff_diff:did_had_pretest_workflow[outcome_col]` | `diff_diff:did_had_pretest_workflow[outcome]` | Rename the keyword: `outcome_col=` becomes `outcome=`. | +| M-109 | renames-col-suffix | `diff_diff:did_had_pretest_workflow[dose_col]` | `diff_diff:did_had_pretest_workflow[dose]` | Rename the keyword: `dose_col=` becomes `dose=`. | +| M-110 | renames-col-suffix | `diff_diff:did_had_pretest_workflow[time_col]` | `diff_diff:did_had_pretest_workflow[time]` | Rename the keyword: `time_col=` becomes `time=`. | +| M-111 | renames-col-suffix | `diff_diff:did_had_pretest_workflow[unit_col]` | `diff_diff:did_had_pretest_workflow[unit]` | Rename the keyword: `unit_col=` becomes `unit=`. | +| M-112 | renames-col-suffix | `diff_diff:did_had_pretest_workflow[first_treat_col]` | `diff_diff:did_had_pretest_workflow[first_treat]` | Rename the keyword: `first_treat_col=` becomes `first_treat=`. | +| M-113 | renames-col-suffix | `diff_diff:trim_weights[weight_col]` | `diff_diff:trim_weights[weights]` | Rename the keyword: `weight_col=` becomes `weights=`. | +| M-043 | renames-control-group | `diff_diff:StackedDiD[clean_control]` | `diff_diff:StackedDiD[control_group]` | Rename the keyword: `clean_control=` becomes `control_group=`. | +| M-095 | renames-control-group | `diff_diff:StackedDiDResults.clean_control` | `diff_diff:StackedDiDResults.control_group` | Read `control_group` instead of `clean_control` on the results object. | +| M-033 | renames-dcdh | `diff_diff:ChaisemartinDHaultfoeuille.fit[group]` | `diff_diff:ChaisemartinDHaultfoeuille.fit[unit]` | Rename the keyword: `group=` becomes `unit=`. | +| M-034 | renames-dcdh | `diff_diff:ChaisemartinDHaultfoeuille.fit[controls]` | `diff_diff:ChaisemartinDHaultfoeuille.fit[covariates]` | Rename the keyword: `controls=` becomes `covariates=`. | +| M-097 | renames-dcdh | `diff_diff:twowayfeweights[group]` | `diff_diff:twowayfeweights[unit]` | Rename the keyword: `group=` becomes `unit=`. | +| M-114 | renames-dcdh | `diff_diff:ChaisemartinDHaultfoeuilleResults.groups` | `diff_diff:ChaisemartinDHaultfoeuilleResults.units` | Read `units` instead of `groups` on the results object. | +| M-044 | renames-level | `diff_diff:WooldridgeDiDResults.to_dataframe[aggregation]` | `diff_diff:WooldridgeDiDResults.to_dataframe[level]` | Rename the keyword: `aggregation=` becomes `level=`. | +| M-086 | renames-level | `diff_diff:WooldridgeDiDResults.aggregate[type]=event` | `diff_diff:WooldridgeDiDResults.aggregate[type]=event_study` | Change the accepted value: `"event"` becomes `"event_study"`. | +| M-087 | renames-level | `diff_diff:WooldridgeDiDResults.summary[aggregation]` | — | `summary()` unifies to the library-wide `summary(alpha=None)` signature - drop `aggregation=` and select the level on `aggregate()` instead. | +| M-136 | renames-level | `diff_diff:LPDiDResults.to_dataframe[level]=event` | `diff_diff:LPDiDResults.to_dataframe[level]=event_study` | Change the accepted value: `"event"` becomes `"event_study"`. | +| M-030 | renames-post | `diff_diff:DifferenceInDifferences.fit[time]` | `diff_diff:DifferenceInDifferences.fit[post]` | Rename the keyword: `time=` becomes `post=`. | +| M-137 | renames-post | `diff_diff:permutation_test[time]` | `diff_diff:permutation_test[post]` | Rename the keyword: `time=` becomes `post=`. | +| M-138 | renames-post | `diff_diff:leave_one_out_test[time]` | `diff_diff:leave_one_out_test[post]` | Rename the keyword: `time=` becomes `post=`. | +| M-045 | renames-robust-drop | `diff_diff:DifferenceInDifferences[robust]` | — | Drop `robust=True` (already the `hc1` default); `robust=False` becomes `vcov_type="classical"`. | +| M-046 | renames-robust-drop | `diff_diff:TripleDifference[robust]` | — | Drop it. `TripleDifference` accepts only `vcov_type="hc1"`, so `robust=` never changed its inference - do NOT write `vcov_type="classical"`, which raises. | +| M-047 | renames-robust-drop | `diff_diff:HeterogeneousAdoptionDiD[robust]` | — | HAD's legacy default is non-robust: `robust=True` becomes `vcov_type="hc1"`; drop `robust=False`. | +| M-115 | renames-robust-drop | `diff_diff:LinearRegression[robust]` | — | Drop `robust=True` (already the `hc1` default); `robust=False` becomes `vcov_type="classical"`. | +| M-093 | results-contract | `diff_diff:CallawaySantAnnaResults.event_study_effects` | `diff_diff:EventStudyResults` | The sentinel is retired - read the unified `EventStudyResults` surface instead of the raw field. | diff --git a/docs/r_comparison.rst b/docs/r_comparison.rst index 6c46a56b..5cd4b3c7 100644 --- a/docs/r_comparison.rst +++ b/docs/r_comparison.rst @@ -44,6 +44,92 @@ Overview - ``fwildclusterboot`` - N/A +Argument mapping +---------------- + +diff-diff deliberately ships **no argument aliases** for the R spellings. Where the +library owns a concept it uses its own name; where a name is the field's language it +uses that. This table is the translation layer that replaces aliasing — it is the +answer to "why isn't ``first_treat`` spelled ``gname``". + +``Where it lives`` names the callable that accepts the argument, since not every R +argument maps onto ``fit()``. + +.. list-table:: + :header-rows: 1 + :widths: 12 20 24 26 18 + + * - R package + - R argument + - diff-diff equivalent + - Where it lives + - Notes + * - ``did`` + - ``yname`` + - ``outcome`` + - ``CallawaySantAnna.fit`` + - + * - ``did`` + - ``tname`` + - ``time`` + - ``CallawaySantAnna.fit`` + - + * - ``did`` + - ``idname`` + - ``unit`` + - ``CallawaySantAnna.fit`` + - + * - ``did`` + - ``gname`` + - ``first_treat`` + - ``CallawaySantAnna.fit`` + - First treated period; ``0`` marks never-treated + * - ``did`` + - ``xformla`` + - ``covariates`` + - ``CallawaySantAnna.fit`` + - A list of column names, not a formula + * - ``did`` + - ``est_method`` + - ``estimation_method`` + - ``CallawaySantAnna`` + - Constructor, not ``fit()`` + * - ``did`` + - ``aggte(type=)`` + - ``type`` + - ``CallawaySantAnnaResults.aggregate`` + - Post-fit, not a fit argument. ``"dynamic"`` becomes ``"event_study"`` + * - ``HonestDiD`` + - ``Mbarvec`` / ``Mvec`` + - ``M_grid`` + - ``HonestDiD.sensitivity_analysis`` + - + * - ``HonestDiD`` + - ``betahat`` / ``sigma`` + - carried by the fitted results + - results object + - Pass the results object itself; there is no coefficient/vcov argument + * - ``HonestDiD`` + - ``numPrePeriods`` / ``numPostPeriods`` + - inferred from the results + - results object + - Derived from the event-study surface, never passed + * - ``synthdid`` + - ``Y`` (outcome matrix) + - ``outcome`` + - ``SyntheticDiD.fit`` + - Long-format column name, not a matrix + * - ``synthdid`` + - ``N0`` / ``T0`` (control/pre counts) + - ``treatment`` + ``post_periods`` + - ``SyntheticDiD.fit`` + - Not a 1:1 mapping: a time-invariant treatment indicator plus the post-period list replaces the block structure + +``fixest``, ``DIDmultiplegtDYN`` and ``DIDHAD`` are discussed on this page in prose +rather than with paired examples, so they are deliberately absent here rather than +mapped from memory. See :doc:`migration-4.0` for the 4.0 renames, which change several +of the diff-diff spellings above. + Package Correspondence ---------------------- @@ -118,7 +204,8 @@ staggered DiD. Here's how to translate common operations: .. code-block:: python - # Python (unlike R's aggte(), aggregation is requested at fit time) + # Python (R's aggte() has two counterparts: the fit-time aggregate= shown here, + # deprecated in 3.9, and post-fit results.aggregate(type=), which supersedes it) results = cs.fit(data, outcome='Y', time='period', unit='id', first_treat='G', aggregate='all') overall_att = results.overall_att # Simple aggregation @@ -451,7 +538,7 @@ Migration Tips 2. **Formula interface**: diff-diff supports R-style formulas for basic DiD: ``formula='y ~ treated * post'`` -3. **Results access**: Use ``.att``, ``.se``, ``.ci`` instead of ``$att``, ``$se`` +3. **Results access**: Use ``.att``, ``.se``, ``.conf_int`` instead of ``$att``, ``$se`` 4. **Visualization**: ``plot_event_study()`` produces matplotlib figures similar to ``ggdid()`` output diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 742f5289..ce48c310 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -1,13 +1,14 @@ .. meta:: - :description: The diff-diff user guide — scholarly references, validation against R and Python packages, benchmarks, and the full methodology registry. - :keywords: DiD methodology, R did package comparison, DiD benchmarks, econometrics references + :description: The diff-diff user guide — scholarly references, validation against R and Python packages, benchmarks, the 4.0 migration guide, and the full methodology registry. + :keywords: DiD methodology, R did package comparison, DiD benchmarks, econometrics references, diff-diff 4.0 migration User Guide ========== The methods behind the library: scholarly references, validation against R -and Python implementations, performance benchmarks, and the methodology -registry documenting every estimator's equations and edge cases. +and Python implementations, performance benchmarks, the migration guide for +the upcoming 4.0 release, and the methodology registry documenting every +estimator's equations and edge cases. .. grid:: 1 2 2 3 :gutter: 3 @@ -32,6 +33,13 @@ registry documenting every estimator's equations and edge cases. How diff-diff compares to other Python causal-inference libraries. + .. grid-item-card:: Migrating to 4.0 + :link: migration-4.0 + :link-type: doc + + Every breaking change in the upcoming 4.0 release, with the one-line + fix for each and a codemod table for the mechanical renames. + .. grid-item-card:: Benchmarks :link: benchmarks :link-type: doc @@ -59,6 +67,7 @@ registry documenting every estimator's equations and edge cases. references R Comparison Python Comparison + Migrating to 4.0 benchmarks Methodology Registry Reporting diff --git a/docs/v4-design.md b/docs/v4-design.md index 9373f1d7..817937c4 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -893,9 +893,31 @@ not expressible as ledger rows, so the 3.9 release PR asserts them by hand: `tests/test_base_estimator.py` (25 classes converted; the ten formerly-lazy set_params surfaces now validate eagerly, REGISTRY EfficientDiD note updated in the same diff). -2. The R-equivalents mapping table (section 8 rule 8) ships in the docs. -3. The migration guide exists (section 10) and its TL;DR table has a row per +2. The R-equivalents mapping table (section 8 rule 8) ships in the docs. DONE: + the `Argument mapping` section of `docs/r_comparison.rst`, gated by + `tests/test_docs_ia.py::test_r_argument_table_is_complete_and_real` + (two-directional: every listed diff-diff name must resolve on the callable + the row names, and the rule-8 quartet plus the page's evidenced mappings + must stay listed). Scoped to the three packages the page evidences with + paired R/Python code blocks - `did`, `HonestDiD`, `synthdid`; `fixest`, + `DIDmultiplegtDYN` and `DIDHAD` appear there in prose only, so they are + named as out of scope rather than mapped from memory. The R side itself is + ungateable (the repo ships no R package signatures) and the test says so. +3. The migration guide exists (section 10) and its appendix has a row per breaking change, generated against the matrix rather than hand-listed. + DONE: `docs/migration-4.0.md`, gated by the + `test_migration_guide_*` family in `tests/test_v4_matrix.py`. + AMENDED 2026-08-09: "generated against the matrix" is true of the appendix's + ROW SET - `_changes_at_4_0` derives it from the ledger and the gates assert + it in both directions, including per-cell `Group`/`Old`/`New` equality. It is + deliberately NOT true of the `Fix` column, which is hand-written per row. + Seven plan-review rounds established why: every attempt to derive migration + advice from a single ledger field was falsified by execution (`introduced_in` + is a storage-flip date, not an availability date; a null `new` covers + removals, default flips AND translations; a successor that resolves can still + raise). The gates therefore assert what is mechanically checkable and refuse + a no-op `Fix` on a null-successor row, rather than pretending the advice is + derivable. 4. The ledger and this document agree on the phase breakdown in the table above - any PR that re-scoped a phase edited both. Invariant and enforcement spec: `tests/test_naming_guard.py` (module docstring). @@ -937,6 +959,37 @@ major"/"flip") to catch anything born outside the matrix. and the alias-diet three: CDiD, Stacked, Gardner). 8. Codemod section: the mechanical renames as a script/regex table. +**(Amended 2026-08-09, Phase 4 ship.)** The skeleton above is what shipped, with +five recorded deviations: + +- **Two tables, not one.** A literal "TL;DR" over 108 rows is not a TL;DR, so §1 is + a 21-row orientation table (one per ledger `group` with at least one qualifying + row) plus a complete 108-row appendix for symbol lookup. Both are gated. +- **No `Available since` column.** Considered and rejected: it cannot be derived. + `introduced_in` tracks the dataclass storage flip, so the nine `field-flip` rows + say `4.0` while `.att` already resolves today; and a successor that exists can + still raise (the five bootstrapped-fit `aggregate()` families). Availability is + stated in prose where it is verifiable instead. +- **§7b "Remaining 4.0 changes"** was added: §§2-8 as skeletoned cover only 102 of + the 108 qualifying rows, leaving `obligation-sdid-params`, `constructor-hygiene`, + `diagnostic-family` and `obligation-warning-retirements` homeless. It is not + titled "removals" - M-007 is a warning retirement and M-090 a roster move. +- **§9 "Already shipped in 3.9"** was added for the 12 already-shipped `behavior` + rows, which moved behaviour but carry no 4.0 action. Split three ways (numbers + moved / fail-closed validation / additive API), because filing M-081 under + "defaults that moved numbers" would contradict its own note ("No numeric default + changes"). Outside the appendix and outside the 4.0 gates. +- **The codemod table covers identifier renames only** - not the five dropped + params (no successor to write), not the three `field` renames (`.groups` -> + `.units` would rewrite every pandas `groupby(...).groups` in user code), and not + the two `param-value` rows (they rename accepted strings, not names). + +The page is MyST markdown published through the existing `myst_parser` config; it +is the third in-site markdown page, toctree'd under the User Guide section (the +root toctree stays at five entries). `TODO.md`/`DEFERRED.md`/this file/the ledger +YAML are referenced as inline literals, never links - none is a Sphinx document, so +a MyST link to them fails the `-W` build. + ## 11. Matrix mechanics (normative schema for `docs/v4-deprecations.yaml`) **Fields.** `id` (`M-###`, unique, never reused), `kind`, `group` (required diff --git a/tests/test_docs_ia.py b/tests/test_docs_ia.py index 74bc16b2..f232d171 100644 --- a/tests/test_docs_ia.py +++ b/tests/test_docs_ia.py @@ -635,3 +635,106 @@ def test_stub_parser_flags_no_index_classes_but_not_functions(): _stub_no_index_autoclasses(bad_class + ".. autofunction:: example\n :no-index:\n") == [] ) assert _stub_no_index_autoclasses(bad_class + ".. autoclass:: Example\n :no-members:\n") == [] + + +# --------------------------------------------------------------------------- +# R-equivalents argument table (docs/r_comparison.rst) - the rule-8 obligation +# (docs/v4-design.md section 8 rule 8, asserted by hand per section 9 item 2) +# --------------------------------------------------------------------------- +#: Rows whose diff-diff side is carried by a fitted results object rather than by a +#: named parameter. They are exempt from the resolve check (there is no signature to +#: look in) but NOT from the presence floor below. +_R_TABLE_RESULTS_MARKER = "results object" + +#: The mappings this table exists to publish. Rule 8 names the first four explicitly; +#: the rest are the ones the page already evidences with paired R/Python code blocks, +#: so losing any of them would silently gut the table while leaving it "present". +_R_TABLE_REQUIRED = { + "yname": "outcome", + "tname": "time", + "idname": "unit", + "gname": "first_treat", + "xformla": "covariates", + "est_method": "estimation_method", + "aggte(type=)": "type", + "Mbarvec / Mvec": "M_grid", +} +#: At least one row per in-scope package, so a whole block cannot vanish. +_R_TABLE_REQUIRED_PACKAGES = {"did", "HonestDiD", "synthdid"} + + +def _r_argument_table_rows(): + """Parse the `Argument mapping` list-table into (package, r_arg, dd_equiv, where) tuples.""" + body = _section_body((DOCS / "r_comparison.rst").read_text(), "Argument mapping") + rows, current = [], [] + for line in body.splitlines(): + stripped = line.strip() + if stripped.startswith("* - "): + if current: + rows.append(current) + current = [stripped[4:].strip()] + elif stripped.startswith("- ") and current: + current.append(stripped[2:].strip()) + if current: + rows.append(current) + # drop the header row + return [r for r in rows if r and r[0] != "R package"] + + +def _strip_rst_code(cell): + return cell.replace("``", "").strip() + + +def test_r_argument_table_is_complete_and_real(): + """Both directions: every listed diff-diff name must exist where the row says it + lives, and every mapping the table exists to publish must still be listed. + + The forward direction resolves against the SPECIFIC callable named in the row's + ``Where it lives`` cell, not against any public callable - names like ``time`` or + ``unit`` would otherwise be satisfied by an unrelated signature. + + The R side cannot be gated: the repo ships no R package signatures, so nothing here + can prove ``yname`` is still ``did``'s spelling. That half is a human review job. + """ + import importlib + import inspect + + rows = _r_argument_table_rows() + assert rows, "the Argument mapping table is missing or its list-table shape changed" + + problems = [] + for row in rows: + if len(row) < 4: + problems.append(f"malformed row: {row}") + continue + _pkg, _r_arg, dd_equiv, where = (_strip_rst_code(c) for c in row[:4]) + if where == _R_TABLE_RESULTS_MARKER or not dd_equiv or " " in dd_equiv: + continue # concept row, or prose rather than a single identifier + target, _, attr = where.rpartition(".") + try: + obj = getattr(importlib.import_module("diff_diff"), target or attr) + if target: + obj = getattr(obj, attr) + except AttributeError: + problems.append(f"{where!r} does not resolve on diff_diff") + continue + params = inspect.signature(obj).parameters + if dd_equiv not in params: + problems.append( + f"{dd_equiv!r} is not a parameter of {where} (params: {sorted(params)})" + ) + assert not problems, "R argument table names surfaces that do not exist:\n " + "\n ".join( + problems + ) + + listed = {_strip_rst_code(r[1]): _strip_rst_code(r[2]) for r in rows if len(r) >= 3} + missing = {k: v for k, v in _R_TABLE_REQUIRED.items() if listed.get(k) != v} + assert not missing, ( + "the R argument table lost mappings it exists to publish (rule 8, " + f"docs/v4-design.md section 8): {missing}" + ) + packages = {_strip_rst_code(r[0]) for r in rows} + assert _R_TABLE_REQUIRED_PACKAGES <= packages, ( + f"R argument table is missing an in-scope package block: " + f"{sorted(_R_TABLE_REQUIRED_PACKAGES - packages)}" + ) diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py index 8ecb50fd..51f06367 100644 --- a/tests/test_v4_matrix.py +++ b/tests/test_v4_matrix.py @@ -1026,3 +1026,308 @@ def test_dual_parse_against_real_yaml_when_available(): 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" + + +# --------------------------------------------------------------------------- +# Migration-guide parity (docs/migration-4.0.md) - spec section 9 checklist item 3 +# --------------------------------------------------------------------------- +GUIDE = REPO_ROOT / "docs" / "migration-4.0.md" + +#: Cells that would tell a reader nothing is required of them. Asserted against the +#: `Fix` column of EVERY null-successor row (not just the 12 that are obviously +#: behavioural): `new: null` means "the ledger names no successor locator", which +#: says nothing about whether work is needed, so a no-op phrase is never safe there. +#: Matched as whole phrases, never as substrings - a bare "none" would fire on the +#: legitimate `cluster=None` / `summary(alpha=None)` that two of these cells must state. +_NO_OP_FIX_RE = re.compile( + r"\b(nothing to do|no action (is )?(required|needed)|no change (is )?(required|needed)|n/a)\b", + re.IGNORECASE, +) + + +def _at_4_0(version): + """True when ``version`` is present and denotes 4.0 (``_version_tuple`` raises on None).""" + return version is not None and _version_tuple(version) == (4, 0, 0) + + +def _changes_at_4_0(row): + """Does this row require reader action at the 4.0 cut? + + Keyed on the two LIFECYCLE version fields only - a symbol removed at 4.0, or one + whose deprecation warning starts firing at 4.0 (the ``field-flip`` family, removed + at 5.0). 108 of the 125 rows qualify. + + The 17 that do not, and why (this enumeration is the contract - a reader of the + guide must be able to trust that nothing 4.0-relevant was dropped): + + - 12 ``behavior`` rows with ``introduced_in: 3.9`` and no dep/rem: already shipped + in 3.9, so there is no 4.0 action. They get their own guide section, not an + appendix row. + - ``M-062``, ``M-063``: aliases, introduce-only / all lifecycle fields null. + - ``M-031``, ``M-082``: ``deprecated_in: 3.9`` with ``removed_in: null``, because + the NAME ``time`` survives with a new meaning. Their 4.0 enforcement rows + (``M-085`` and ``M-083`` respectively) DO qualify and carry the raise. + - ``M-008``: ``decision_due: 4.0`` but ``status: evaluate`` with no + ``decided_default``. ``decision_due`` is deliberately NOT part of this predicate: + the outcome is unknown and "evaluated, kept off" is a terminal state (spec + section 11), so it is documented as a pending go/no-go rather than as a change + the reader must act on. ``collect_due_problems`` does gate on ``decision_due`` - + that is the release sweep, a different question from "what must a user change". + """ + return _at_4_0(row.get("removed_in")) or _at_4_0(row.get("deprecated_in")) + + +def _guide_table_rows(text, header_first_cell): + """Parse the MyST pipe table whose header row starts with ``header_first_cell``. + + Returns a list of cell-lists (header and the ``---`` separator dropped). Tables are + discriminated by their header, not by position, so adding prose between them is safe. + """ + rows = [] + in_table = False + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + in_table = False + continue + cells = [c.strip() for c in stripped.strip("|").split("|")] + if not in_table: + if cells and cells[0] == header_first_cell: + in_table = True + continue + if all(set(c) <= set("-: ") for c in cells): # the |---|---| separator + continue + rows.append(cells) + return rows + + +def _qualifying_rows(): + return [r for r in ROWS if _changes_at_4_0(r)] + + +def _appendix_rows(): + return _guide_table_rows(GUIDE.read_text(), "Row") + + +def _orientation_rows(): + return _guide_table_rows(GUIDE.read_text(), "Area") + + +def test_migration_guide_exists(): + """Spec section 9 checklist item 3. Every gate below reads this file.""" + assert GUIDE.exists(), ( + f"{GUIDE.relative_to(REPO_ROOT)} is missing - it is checklist item 3 of the 3.9-cut " + "checklist (docs/v4-design.md section 9)." + ) + + +def test_migration_guide_appendix_covers_every_4_0_change(): + """The appendix has EXACTLY one row per ledger row that changes at 4.0. + + Compared as multisets, not sets: a duplicated M-id would satisfy set equality while + breaking the appendix's stated contract that a ctrl-F on a symbol lands once. + """ + from collections import Counter + + expected = Counter(r["id"] for r in _qualifying_rows()) + actual = Counter(cells[0] for cells in _appendix_rows()) + missing = sorted((expected - actual).elements()) + stale = sorted((actual - expected).elements()) + assert not missing and not stale, ( + f"migration guide appendix drifted from the ledger:\n" + f" missing rows (in ledger, absent from the guide): {missing}\n" + f" stale/duplicated rows (in the guide, not a qualifying ledger row): {stale}" + ) + + +def test_migration_guide_appendix_cells_match_the_ledger(): + """Group/Old/New are compared against the ledger literally; Fix must be substantive. + + ``New`` has exactly three legal renderings, and the gate must encode all three or it + cannot pass its own prescribed table: + 1. the ledger's ``new`` locator verbatim; + 2. an em dash where ``new`` is null; + 3. the locator plus the unavailable marker, for successors the ledger names but + that do not exist yet (M-140/M-141). + """ + by_id = {r["id"]: r for r in _qualifying_rows()} + problems = [] + for cells in _appendix_rows(): + rid, group, old, new, fix = (cells + [""] * 5)[:5] + # The guide renders locators as inline code; the ledger stores them bare. + old = old.strip("`") + row = by_id.get(rid) + if row is None: + continue # covered by the coverage gate above + if group != (row.get("group") or ""): + problems.append(f"{rid}: Group is {group!r}, ledger says {row.get('group')!r}") + if old != (row.get("old") or ""): + problems.append(f"{rid}: Old is {old!r}, ledger says {row.get('old')!r}") + expected_new = row.get("new") + if expected_new is None: + if new != "—": + problems.append( + f"{rid}: ledger has no successor; New must be an em dash, got {new!r}" + ) + elif expected_new not in new: + problems.append(f"{rid}: New is {new!r}, ledger says {expected_new!r}") + if not fix.strip(): + problems.append(f"{rid}: Fix cell is empty") + elif expected_new is None and _NO_OP_FIX_RE.search(fix): + problems.append( + f"{rid}: Fix reads {fix!r}. A null successor does NOT mean no action - this row " + "is a default flip, behaviour change, translation or replacement; state it." + ) + assert ( + not problems + ), "migration guide appendix cells disagree with the ledger:\n " + "\n ".join(problems) + + +def test_migration_guide_appendix_is_sorted(): + """Sorted by group, then id - the appendix's stated lookup contract.""" + keys = [(cells[1], cells[0]) for cells in _appendix_rows()] + assert keys == sorted(keys), "appendix rows are not sorted by (group, id)" + + +def test_migration_guide_orientation_table_covers_every_group(): + """One orientation row per group with at least one qualifying row, and no others.""" + expected = {r.get("group") for r in _qualifying_rows()} + actual = {cells[0] for cells in _orientation_rows()} + missing, stale = sorted(expected - actual), sorted(actual - expected) + assert not missing and not stale, ( + f"orientation table drifted:\n missing groups: {missing}\n" + f" groups with zero qualifying rows (or typos): {stale}" + ) + + +# --- parser unit tests: a table parser that silently matches nothing would make every +# --- gate above vacuously green, so the parser is pinned on hand-built input. +_SAMPLE_TABLE = """ +Some prose that is not a table. + +| Row | Group | Old | New | Fix | +|---|---|---|---|---| +| M-001 | grp-a | `diff_diff:A.b` | — | Drop it. | +| M-002 | grp-b | `diff_diff:C.d` | `diff_diff:C.e` | Rename it. | + +More prose. + +| Area | What changes | Rows | Where | +|---|---|---|---| +| grp-a | something | 1 | S1 | +""" + + +def test_table_parser_finds_rows_and_discriminates_by_header(): + appendix = _guide_table_rows(_SAMPLE_TABLE, "Row") + assert [c[0] for c in appendix] == ["M-001", "M-002"], "appendix rows not parsed" + assert appendix[0][3] == "—" and appendix[1][3] == "`diff_diff:C.e`" + orientation = _guide_table_rows(_SAMPLE_TABLE, "Area") + assert [c[0] for c in orientation] == ["grp-a"], "orientation table not discriminated by header" + + +def test_table_parser_returns_nothing_for_an_absent_header(): + """The vacuity guard: an unknown header yields no rows, so a gate keyed on a typo'd + header would fail loudly rather than pass with an empty comparison set.""" + assert _guide_table_rows(_SAMPLE_TABLE, "Nonexistent") == [] + + +def test_appendix_parser_is_not_vacuous_on_the_real_guide(): + """Pins the live parse against the ledger's own count - if the guide's table format + drifts (a renamed header, a switch to list-tables), every other gate would compare two + empty sets and pass.""" + assert len(_appendix_rows()) == len(_qualifying_rows()) > 100 + + +def _guide_python_blocks(text): + """Source of every ```python fenced block in the guide.""" + return re.findall(r"^```python\n(.*?)^```", text, re.M | re.S) + + +def _diff_diff_call_keywords_with(source, diff_diff): + """(target, keyword) pairs for calls this module can resolve to a diff_diff export. + + Covers the two shapes the guide's worked examples use: ``Estimator(...)`` (constructor + keywords) and ``Estimator(...).fit(...)`` (fit keywords). Calls on names it cannot + resolve - ``results.aggregate("event_study")`` - are skipped rather than guessed at. + """ + import ast + + pairs = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Name): + target, owner = func.id, func.id + elif ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Call) + and isinstance(func.value.func, ast.Name) + ): + target, owner = f"{func.value.func.id}.{func.attr}", func.value.func.id + else: + continue + if not hasattr(diff_diff, owner): + continue + for kw in node.keywords: + if kw.arg: + pairs.append((target, kw.arg)) + return pairs + + +def test_migration_guide_examples_bind_to_real_signatures(): + """Every keyword in the guide's worked examples must exist on the callable it targets. + + The guide is markdown, so ``tests/test_doc_snippets.py`` (RST-only) never executes it - + a wrong keyword would otherwise ship silently. This is the targeted substitute: it binds + the examples to real signatures without executing 4.0-only behaviour. + + It exists because the first local review of this page found EVERY merge example carrying + an invalid keyword (``treated=`` for ``treatment=``, ``partition=`` for ``eligibility=``, + ``post=`` for ``time=``) - each one plausible, each one wrong. + """ + import importlib + import inspect + + diff_diff = importlib.import_module("diff_diff") + blocks = _guide_python_blocks(GUIDE.read_text()) + pairs = [p for b in blocks for p in _diff_diff_call_keywords_with(b, diff_diff)] + + # Vacuity floor: without it, deleting every example - or renaming the ```python + # fence - empties the loop below and the gate passes while checking nothing. + assert ( + len(blocks) >= 4 + ), f"expected at least 4 ```python blocks in the guide, found {len(blocks)}" + assert len(pairs) >= 20, f"expected at least 20 resolvable keywords, found {len(pairs)}" + for owner in ( + "MultiPeriodDiD", + "TwoWayFixedEffects", + "StaggeredTripleDifference", + "TripleDifference", + "QDiD", + "ChangesInChanges", + ): + assert any( + t.split(".")[0] == owner for t, _ in pairs + ), f"the {owner} worked example vanished from the guide" + + problems = [] + for block in blocks: + for target, keyword in _diff_diff_call_keywords_with(block, diff_diff): + owner, _, method = target.partition(".") + obj = getattr(diff_diff, owner) + if method: + obj = getattr(obj, method, None) + if obj is None: + problems.append(f"{target}: {owner} has no {method}()") + continue + params = inspect.signature(obj).parameters + if keyword not in params: + problems.append( + f"{target}({keyword}=...) - not a parameter " + f"(accepts: {sorted(p for p in params if p != 'self')})" + ) + assert ( + not problems + ), "migration guide examples use keywords that do not exist:\n " + "\n ".join(problems)