From a7e8c716b3cc6346ef80c87961a83ae0635cd3a4 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 18 Oct 2026 12:07:29 -0400 Subject: [PATCH] feat(v4): PR 1 of the 4.0 cut - new-estimator results-contract conformance Brings the estimators born after 3.9 onto the 4.0 results contract (spec sections 5 and 6) before the removal waves start. - LWDiDResults.summary()/print_summary() take alpha=None under the family-wide M-146 guard (previously no alpha at all). - LWDiDResults.to_dataframe(level=) / to_csv(level=) are aggregate-backed on the shared schemas (+ rolling/estimation_method/vcov_type), default "simple"; the 3.10 bespoke frame is gone (ledger behavior row M-153). - LWDiDResults.pvalue / .ci alias properties deprecated (FutureWarning, removed 5.0; ledger rows M-151/M-152) via deprecated_field_property, which gains removed_in= so warning and docstring name the right release. - DurationDiDResults.aggregate("simple") relays the stored quintet (df NaN). - RDDensityTestResult.to_dict() added. - Ledger: rows M-151..M-153 (group lwdid-results-contract), M-146 code_refs; id snapshot/floor 135; naming guard tokens pvalue/ci (ambiguous lanes) + allowlists; spec sections 3.1/5/9/11, migration guide, REGISTRY Notes, llms-full, lwdid.rst updated; tests for every surface. --- changelog.d/20261018-v4-pr1-conformance.md | 30 ++++ diff_diff/_deprecation.py | 15 +- diff_diff/duration_did_results.py | 33 ++++- diff_diff/guides/llms-full.txt | 5 +- diff_diff/lwdid_results.py | 155 ++++++++++----------- diff_diff/rddensity.py | 60 ++++++++ docs/api/lwdid.rst | 2 +- docs/methodology/REGISTRY.md | 17 +++ docs/migration-4.0.md | 33 ++++- docs/v4-deprecations.yaml | 46 +++++- docs/v4-design.md | 33 +++-- tests/test_duration_did.py | 22 ++- tests/test_lwdid.py | 136 ++++++++++++++++++ tests/test_naming_guard.py | 43 ++++++ tests/test_rddensity.py | 59 ++++++++ tests/test_v4_matrix.py | 22 +-- tests/test_v4_rename_mechanical.py | 17 +++ 17 files changed, 604 insertions(+), 124 deletions(-) create mode 100644 changelog.d/20261018-v4-pr1-conformance.md diff --git a/changelog.d/20261018-v4-pr1-conformance.md b/changelog.d/20261018-v4-pr1-conformance.md new file mode 100644 index 000000000..df6cdd6e4 --- /dev/null +++ b/changelog.d/20261018-v4-pr1-conformance.md @@ -0,0 +1,30 @@ +### Added +- **`LWDiDResults.summary(alpha=None)` / `print_summary(alpha=None)`** join the family-wide + summary-alpha guard (ledger row `M-146`): a value other than the fit-time `alpha` raises + `ValueError` instead of being ignored; the methods previously took no `alpha` at all. +- **`LWDiDResults.to_dataframe(level=...)` / `to_csv(path, level=...)`** gain the uniform + `level` selector (`"simple"` default, `"group"`, `"event_study"`) - see Breaking Changes. +- **`DurationDiDResults.aggregate("simple")`**: a verbatim relay of the stored headline + quintet (`n = n_units`, `df` NaN - inference is bootstrap-only), joining the spec section 6 + `simple` contract every aggregation adopter honours. +- **`RDDensityTestResult.to_dict()`**: JSON-serializable scalars with native casts (NaN + inference passes through), the `*_conventional` block `None` unless `report_all=True`, and + the bandwidth-selection table nested under `bandwidths` only when it was estimated. +- `diff_diff._deprecation.deprecated_field_property` takes `removed_in=` so the warning and the + generated docstring name the row's actual removal release. + +### Deprecated +- **`LWDiDResults.pvalue` and `LWDiDResults.ci`** (ledger rows `M-151`, `M-152`): the + 3.10-born alias properties for `p_value` / `conf_int` now emit a `FutureWarning` and are + removed in 5.0. Read `.p_value` / `.conf_int`. + +### Breaking Changes +- **`LWDiDResults.to_dataframe()` is reshaped onto the shared results schema** (ledger row + `M-153`): each `level` returns `aggregate(level).to_dataframe()` - columns `level, label, + target, att, se, t_stat, p_value, conf_int_lower, conf_int_upper, n, weight, df` (the + event-study schema for `level="event_study"`) - plus LWDiD's `rolling`, `estimation_method` + and `vcov_type` columns. The default `"simple"` is ONE headline row; the former per-cohort + rows are `level="group"` (without the old trailing `"Overall"` row). The `term` / `cohort` + column is now `label`, `n_treated` is the schema's `n`, `n_obs` and `n_control` are no longer + in the frame (both stay in `to_dict()`), and `ci_lower` / `ci_upper` are + `conf_int_lower` / `conf_int_upper`. `to_csv()` follows and takes the same `level`. diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py index 3ea91d8a5..26c641e69 100644 --- a/diff_diff/_deprecation.py +++ b/diff_diff/_deprecation.py @@ -151,24 +151,31 @@ def require_arg(qualname: str, name: str, value: Any) -> None: raise TypeError(f"{qualname}() missing required argument: '{name}'") -def deprecated_field_property(cls_name: str, old: str, new: str) -> property: +def deprecated_field_property( + cls_name: str, old: str, new: str, *, removed_in: str = "4.0" +) -> property: """Build the read-only warning alias for a renamed results field. Assigned WITHOUT an annotation in the dataclass body, so it stays a plain descriptor (never a ``__dataclass_fields__`` entry). Wording follows the ``SyntheticDiDResults`` renamed-field alias precedent (M-003, ``diff_diff/results.py``), with the FutureWarning category the - ledger declares for the M-094/M-095/M-114 rows. + ledger declares for the M-094/M-095/M-114 rows. ``removed_in`` names the + release the ledger row schedules the removal for (``"4.0"`` for the 3.9 + rename sweep; ``"5.0"`` for aliases deprecated AT 4.0, e.g. the LWDiD + ``pvalue``/``ci`` rows M-151/M-152) so the warning and the generated + docstring never contradict the row. """ def _get(self: Any) -> Any: warnings.warn( - f"{cls_name}.{old} is deprecated; use {new} instead. " "Will be removed in 4.0.", + f"{cls_name}.{old} is deprecated; use {new} instead. " + f"Will be removed in {removed_in}.", FutureWarning, stacklevel=2, ) return getattr(self, new) _get.__name__ = old - _get.__doc__ = f"Deprecated alias for :attr:`{new}` (removed in 4.0)." + _get.__doc__ = f"Deprecated alias for :attr:`{new}` (removed in {removed_in})." return property(_get) diff --git a/diff_diff/duration_did_results.py b/diff_diff/duration_did_results.py index a8f15b222..50b900034 100644 --- a/diff_diff/duration_did_results.py +++ b/diff_diff/duration_did_results.py @@ -23,7 +23,7 @@ import numpy as np import pandas as pd -from diff_diff.aggregation import AggregationMixin +from diff_diff.aggregation import AggregationMixin, AggregationResult from diff_diff.results import _get_significance_stars from diff_diff.results_base import ( BaseResults, @@ -359,7 +359,7 @@ class DurationDiDResults(BaseResults, AggregationMixin): n_draws_invalid_counterfactual: int pretest: DurationDiDPretestResults - _AGGREGATE_SUPPORTED: ClassVar[Tuple[str, ...]] = ("event_study",) + _AGGREGATE_SUPPORTED: ClassVar[Tuple[str, ...]] = ("simple", "event_study") _AGGREGATE_BALANCE_E_TYPES: ClassVar[Tuple[str, ...]] = () # ------------------------------------------------------------------ # @@ -545,6 +545,35 @@ def _aggregate_compute( weights: Optional[str], balance_e: Optional[int], ) -> Any: + """Build the ``"simple"`` relay or the ``"event_study"`` container. + + ``"simple"`` relays the stored headline quintet verbatim (spec section + 6: a relay never recomputes). DurationDiD's inference is + bootstrap-only - ``se`` is the centred-bootstrap SD and the interval + the symmetric ``point +/- crit * se`` (REGISTRY: no percentile + intervals), ``t_stat`` the stored analytical z, all NaN when + ``inference_status != "ok"`` - so no finite df governs it and the + ``df`` column is NaN. ``"event_study"`` builds the unified container. + """ + if level == "simple": + ci_lo, ci_hi = self.conf_int + return AggregationResult( + level="simple", + label=np.array(["overall"], dtype=object), + target=np.array(["att"], dtype=object), + att=np.array([self.att], dtype=float), + se=np.array([self.se], dtype=float), + t_stat=np.array([self.t_stat], dtype=float), + p_value=np.array([self.p_value], dtype=float), + conf_int_lower=np.array([ci_lo], dtype=float), + conf_int_upper=np.array([ci_hi], dtype=float), + n=np.array([float(self.n_units)], dtype=float), + df=None, + alpha=self.alpha, + n_kind="units", + weight=np.array([1.0], dtype=float), + estimator="DurationDiD", + ) if level != "event_study": # pragma: no cover - mixin validates first raise ValueError(f"Unsupported aggregation method: {level!r}") n_post = len(self.post_periods) diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 6b2ce0365..45f807c58 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -1079,6 +1079,7 @@ res.f_left, res.f_right # per-side density estimates at the cutoff (who res.bandwidths # the rdbwdensity 4-row h-table (None on manual h) res.to_dataframe() # tidy left/right/diff table (+ conventional rows with report_all) print(res.summary()) # R-style block (Conventional row iff report_all=True) +res.to_dict() # JSON-serializable scalars (+ nested 'bandwidths' when estimated) ``` Repeated running-variable values are mass-point adjusted with a warning @@ -1453,7 +1454,7 @@ Key contracts: - `results.att_by_period`, `se_by_period`, `conf_int_by_period` (pointwise), `cband_lower/upper` + `cband_crit_value` (simultaneous), `joint_p_value`, `vcov` (P x P bootstrap covariance), `coefficient` (fitted c), `survival_treated/control`, `counterfactual_survival`, `pre_periods`/`pre_period_weights` (realized), `excluded_pre_periods`. - Every inference family is fully available or fully withheld: `inference_status` in `ok` / `disabled` (n_bootstrap=0) / `unavailable_invalid_periods` (the CD model extrapolated an invalid counterfactual curve — survival above one or a decreasing cumulative hazard — `curve_status`/`period_status` name the dates; the warning names the applicable remedy: a fitted pre-date violation cannot be repaired by a shorter horizon — change `method` or the fitting window; a later post-date violation admits an explicit refit on the dates before the first invalid post date; a first-post-date violation admits no valid shorter horizon) / `unavailable_failed_draws` (any failed draw; fixed draws, no retries, `bootstrap_failure_reasons` per family) / `unavailable_zero_se`. `n_draws_invalid_counterfactual` counts complete draws whose imputed curve left the domain (not failures; a large share signals weak extrapolation support — shown in `summary()`). `p_value <= alpha` is exactly the band rule (`is_significant`). - `results.pretest` (`DurationDiDPretestResults`, a Diagnostic): contrasts of the CD gap / PH ratio at every interior pre-date against the fixed anchor `last_pre_period`, simultaneous band, `statistic`, `p_value`, `reject`, `status` (needs at least three pre-dates). A non-rejection does not establish identification. -- `results.aggregate('event_study')` returns the unified `EventStudyResults` (event time 0 = first post-treatment date, reference -1, `n_kind='units'`); no pre-treatment ATT rows exist (the pretest is not an event study). Do NOT run `check_parallel_trends` or HonestDiD on this estimator; `DiagnosticReport`/`BusinessReport` reject `DurationDiDResults` by type; `practitioner_next_steps` has a dedicated handler. +- `results.aggregate('event_study')` returns the unified `EventStudyResults` (event time 0 = first post-treatment date, reference -1, `n_kind='units'`); no pre-treatment ATT rows exist (the pretest is not an event study). `results.aggregate('simple')` relays the stored headline quintet verbatim (`n = n_units`, `df` NaN - bootstrap-only inference). Do NOT run `check_parallel_trends` or HonestDiD on this estimator; `DiagnosticReport`/`BusinessReport` reject `DurationDiDResults` by type; `practitioner_next_steps` has a dedicated handler. - Not supported in this version: covariates, staggered adoption, censoring, survey weights, cluster dependence, repeated cross-sections. ### TROP @@ -2275,7 +2276,7 @@ Results container shared by `ChangesInChanges` and `QDiD` (the `method` field is ### LWDiDResults -Results from `LWDiD.fit()` (both timing modes). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int` (when inference is unavailable - e.g. psm, degenerate designs - `se`/`t_stat`/`p_value` and both CI bounds are jointly NaN while `att` is RETAINED: the fail-closed contract), plus `n_obs`, `n_treated`, `n_control`, `df_inference`, `cluster_name`/`n_clusters`, and fit provenance (`rolling`, `estimation_method`, `vcov_type`, `alpha`, `control_group`, `n_bootstrap`, `seed`, `pscore_trim`, `psm_config`, `inference_basis`). Staggered fits also carry `cohort_effects` and `cohort_time_effects` dicts. Methods: `summary()`, `print_summary()`, `to_dict()`, `to_dataframe()`, `aggregate(level)` with levels `'simple'` (view-relay of the fitted headline -> AggregationResult), `'event_study'` (per-event-time effects -> EventStudyResults), and `'group'` (per-cohort effects -> AggregationResult; staggered fits only - common-timing fits raise), and - common-timing `estimation_method='reg'` fits ONLY - `randomization_test()` and `wild_cluster_bootstrap()` (the latter ADDITIONALLY requires a `cluster=` fit - unclustered fits raise; both REPLAY the fitted collapsed design; no data arguments accepted; other fits reject informatively). NOTE: there is NO `overall_att` or `period_effects` attribute - those surfaces were retired pre-release (unlike the shared staggered pattern above); use `.att` for the overall effect and `aggregate('event_study')` for per-period effects. +Results from `LWDiD.fit()` (both timing modes). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int` (when inference is unavailable - e.g. psm, degenerate designs - `se`/`t_stat`/`p_value` and both CI bounds are jointly NaN while `att` is RETAINED: the fail-closed contract), plus `n_obs`, `n_treated`, `n_control`, `df_inference`, `cluster_name`/`n_clusters`, and fit provenance (`rolling`, `estimation_method`, `vcov_type`, `alpha`, `control_group`, `n_bootstrap`, `seed`, `pscore_trim`, `psm_config`, `inference_basis`). Staggered fits also carry `cohort_effects` and `cohort_time_effects` dicts. Methods: `summary(alpha=None)` / `print_summary(alpha=None)` (alpha must equal the fit-time alpha - the family-wide guard), `to_dict()`, `to_dataframe(level='simple')` / `to_csv(path, level='simple')` (4.0: aggregate-backed - `aggregate(level).to_dataframe()` plus the `rolling`/`estimation_method`/`vcov_type` columns; `level` in `'simple'` (one headline row, default), `'group'` (per-cohort rows, staggered only), `'event_study'`; columns `conf_int_lower`/`conf_int_upper`, `n` = treated units - `n_obs`/`n_control` live in `to_dict()`), `aggregate(level)` with levels `'simple'` (view-relay of the fitted headline -> AggregationResult), `'event_study'` (per-event-time effects -> EventStudyResults), and `'group'` (per-cohort effects -> AggregationResult; staggered fits only - common-timing fits raise), and - common-timing `estimation_method='reg'` fits ONLY - `randomization_test()` and `wild_cluster_bootstrap()` (the latter ADDITIONALLY requires a `cluster=` fit - unclustered fits raise; both REPLAY the fitted collapsed design; no data arguments accepted; other fits reject informatively). NOTE: there is NO `overall_att` or `period_effects` attribute - those surfaces were retired pre-release (unlike the shared staggered pattern above); use `.att` for the overall effect and `aggregate('event_study')` for per-period effects. ### TROPResults diff --git a/diff_diff/lwdid_results.py b/diff_diff/lwdid_results.py index 86c927768..3d88be899 100644 --- a/diff_diff/lwdid_results.py +++ b/diff_diff/lwdid_results.py @@ -4,13 +4,20 @@ import datetime from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, ClassVar, Dict, List, Optional, Tuple import numpy as np import pandas as pd +from diff_diff._deprecation import deprecated_field_property from diff_diff.aggregation import AggregationMixin, AggregationResult -from diff_diff.results_base import BaseResults, EventStudyResults, _coverage_pct +from diff_diff.results_base import ( + _SUMMARY_ALPHA_MESSAGE, + BaseResults, + EventStudyResults, + _coverage_pct, + _require_fit_alpha, +) # How the overall staggered standard error was obtained. Cohort effects that @@ -223,15 +230,12 @@ class LWDiDResults(BaseResults, AggregationMixin): # ------------------------------------------------------------------ # # Properties # # ------------------------------------------------------------------ # - @property - def pvalue(self) -> float: - """Alias for p_value (diff-diff API convention).""" - return self.p_value - - @property - def ci(self) -> Tuple[float, float]: - """Alias for conf_int (diff-diff API convention).""" - return self.conf_int + # Deprecated 3.10-born aliases for the canonical quintet names (ledger rows + # M-151 / M-152): FutureWarning from 4.0, removed at 5.0. Plain class + # attributes (no annotation) so they stay descriptors, never dataclass + # fields - the ``shimmed`` field-row shape tests/test_v4_matrix.py asserts. + pvalue = deprecated_field_property("LWDiDResults", "pvalue", "p_value", removed_in="5.0") + ci = deprecated_field_property("LWDiDResults", "ci", "conf_int", removed_in="5.0") @property def is_staggered(self) -> bool: @@ -388,74 +392,47 @@ def _column(key: str, default: float = np.nan) -> np.ndarray: # ------------------------------------------------------------------ # # Serialization # # ------------------------------------------------------------------ # - def to_dataframe(self) -> pd.DataFrame: - """Convert results to a pandas DataFrame. + _CONFIG_COLUMNS: ClassVar[Tuple[str, ...]] = ("rolling", "estimation_method", "vcov_type") + + def to_dataframe(self, level: str = "simple") -> pd.DataFrame: + """Convert results to a pandas DataFrame at an aggregation ``level``. + + The frame is the post-fit :meth:`aggregate` surface for ``level`` + (the library-wide aggregation schema: ``level, label, target, att, + se, t_stat, p_value, conf_int_lower, conf_int_upper, n, weight, df``; + the event-study schema for ``"event_study"``) with LWDiD's three + configuration columns ``rolling``, ``estimation_method`` and + ``vcov_type`` appended, so an exported frame stays self-describing. + + Parameters + ---------- + level : {"simple", "event_study", "group"}, default "simple" + ``"simple"``: one headline ATT row (``label == "overall"``). + ``"group"``: one row per cohort ATT(g) - staggered fits only. + ``"event_study"``: one row per relative period, with the + reference periods marked (``is_reference``). Returns ------- pd.DataFrame - For common timing: a single-row DataFrame. - For staggered: one row per cohort plus an "Overall" row. + + Notes + ----- + Changed in 4.0 (ledger row M-153). The 3.10-3.13 frame had no + ``level`` selector and a bespoke shape: a ``term``/``cohort`` column + (now ``label``), a trailing ``"Overall"`` row on staggered fits (now + ``level="simple"``), ``n_treated``/``n_control``/``n_obs`` columns + (now the schema's ``n`` = treated units; the counts stay in + :meth:`to_dict`), and ``ci_lower``/``ci_upper`` (now + ``conf_int_lower``/``conf_int_upper``). :meth:`to_csv` takes the + same ``level``. """ - if not self.is_staggered: - rows: List[Dict[str, Any]] = [ - { - "term": "ATT", - "att": self.att, - "se": self.se, - "t_stat": self.t_stat, - "p_value": self.p_value, - "ci_lower": self.conf_int[0], - "ci_upper": self.conf_int[1], - "n_obs": self.n_obs, - "n_treated": self.n_treated, - "n_control": self.n_control, - "rolling": self.rolling, - "estimation_method": self.estimation_method, - "vcov_type": self.vcov_type, - } - ] - return pd.DataFrame(rows) - - rows_stag: List[Dict[str, Any]] = [] - for cohort, eff in self.cohort_effects.items(): # type: ignore[union-attr] - ci = eff.get("conf_int", (np.nan, np.nan)) - n_t = eff.get("n_treated", 0) - n_c = eff.get("n_control", 0) - rows_stag.append( - { - "cohort": cohort, - "att": eff.get("att", np.nan), - "se": eff.get("se", np.nan), - "t_stat": eff.get("t_stat", np.nan), - "p_value": eff.get("p_value", np.nan), - "ci_lower": ci[0] if ci else np.nan, - "ci_upper": ci[1] if ci else np.nan, - "n_treated": n_t, - "n_control": n_c, - "rolling": self.rolling, - "estimation_method": self.estimation_method, - "vcov_type": self.vcov_type, - } - ) - # Append overall row - rows_stag.append( - { - "cohort": "Overall", - "att": self.att, - "se": self.se, - "t_stat": self.t_stat, - "p_value": self.p_value, - "ci_lower": self.conf_int[0], - "ci_upper": self.conf_int[1], - "n_treated": self.n_treated, - "n_control": self.n_control, - "rolling": self.rolling, - "estimation_method": self.estimation_method, - "vcov_type": self.vcov_type, - } - ) - return pd.DataFrame(rows_stag) + if level not in self._AGGREGATE_SUPPORTED: + raise ValueError(f"Unknown level: {level}. Use 'simple', 'event_study' or 'group'.") + frame = self.aggregate(level).to_dataframe() + for column in self._CONFIG_COLUMNS: + frame[column] = getattr(self, column) + return frame def to_dict(self) -> Dict[str, Any]: """Convert results to a JSON-serializable dictionary. @@ -526,22 +503,33 @@ def to_dict(self) -> Dict[str, Any]: # ------------------------------------------------------------------ # # Aggregation # # ------------------------------------------------------------------ # - def to_csv(self, path: str) -> None: - """Export results to CSV file. + def to_csv(self, path: str, *, level: str = "simple") -> None: + """Export :meth:`to_dataframe` at ``level`` to a CSV file. Parameters ---------- path : str File path for the CSV output. + level : {"simple", "event_study", "group"}, default "simple" + Aggregation level relayed to :meth:`to_dataframe` (4.0: the + former cohort-plus-``Overall`` export is ``level="group"`` plus + the default ``"simple"`` row). """ - self.to_dataframe().to_csv(path, index=False) + self.to_dataframe(level=level).to_csv(path, index=False) # ------------------------------------------------------------------ # # Text summary # # ------------------------------------------------------------------ # - def summary(self) -> str: + def summary(self, alpha: Optional[float] = None) -> str: """Formatted text summary of results. + Parameters + ---------- + alpha : float, optional + Must equal the fit-time ``alpha`` when given: the stored + intervals are never recomputed or relabelled (the family-wide + summary-alpha contract, ledger row M-146). Re-fit to change it. + Returns ------- str @@ -549,7 +537,8 @@ def summary(self) -> str: """ from diff_diff.results import _format_vcov_label, _get_significance_stars - ci_pct = _coverage_pct(self.alpha) + alpha = _require_fit_alpha(alpha, self.alpha, message=_SUMMARY_ALPHA_MESSAGE) + ci_pct = _coverage_pct(alpha) width = 88 bar = "=" * width dash = "-" * width @@ -570,7 +559,7 @@ def _fmt(x: Any, nd: int = 4) -> str: f"Control units: {self.n_control}", f"Rolling: {self.rolling} " f"Method: {self.estimation_method} " - f"Alpha: {self.alpha}", + f"Alpha: {alpha}", ] # Variance label @@ -639,9 +628,9 @@ def _fmt(x: Any, nd: int = 4) -> str: lines.append("Signif. codes: *** p<0.001, ** p<0.01, * p<0.05") return "\n".join(lines) - def print_summary(self) -> None: - """Print the formatted summary to stdout.""" - print(self.summary()) + def print_summary(self, alpha: Optional[float] = None) -> None: + """Print the formatted summary to stdout (``alpha`` as in :meth:`summary`).""" + print(self.summary(alpha)) # ================================================================ # Advanced inference and diagnostics (delegate to standalone modules) diff --git a/diff_diff/rddensity.py b/diff_diff/rddensity.py index 2e18f0ab4..3ad871e82 100644 --- a/diff_diff/rddensity.py +++ b/diff_diff/rddensity.py @@ -771,6 +771,66 @@ class RDDensityTestResult(Diagnostic): n_unique_min: int report_all: bool + def to_dict(self) -> Dict[str, Any]: + """JSON-serializable dictionary of every stored scalar. + + Floats are cast to built-in ``float`` (NaN passes through as NaN, + which the stdlib encoder emits as ``NaN``), the ``*_conventional`` + block stays ``None`` unless the test ran with ``report_all=True``, + and the bandwidth-selection table is nested under ``"bandwidths"`` + (``{row: {column: value}}``) only when it was estimated (a manual + ``h`` leaves it absent). + """ + + def _f(x: Any) -> Optional[float]: + return None if x is None else float(x) + + out: Dict[str, Any] = { + "t_stat": _f(self.t_stat), + "p_value": _f(self.p_value), + "f_left": _f(self.f_left), + "f_right": _f(self.f_right), + "f_diff": _f(self.f_diff), + "se_left": _f(self.se_left), + "se_right": _f(self.se_right), + "se_diff": _f(self.se_diff), + "f_left_conventional": _f(self.f_left_conventional), + "f_right_conventional": _f(self.f_right_conventional), + "f_diff_conventional": _f(self.f_diff_conventional), + "se_left_conventional": _f(self.se_left_conventional), + "se_right_conventional": _f(self.se_right_conventional), + "se_diff_conventional": _f(self.se_diff_conventional), + "t_stat_conventional": _f(self.t_stat_conventional), + "p_value_conventional": _f(self.p_value_conventional), + "n": int(self.n), + "n_left": int(self.n_left), + "n_right": int(self.n_right), + "n_eff_left": int(self.n_eff_left), + "n_eff_right": int(self.n_eff_right), + "h_left": float(self.h_left), + "h_right": float(self.h_right), + "cutoff": float(self.cutoff), + "p": int(self.p), + "q": int(self.q), + "fitselect": str(self.fitselect), + "kernel": str(self.kernel), + "vcov_type": str(self.vcov_type), + "bwselect": str(self.bwselect), + "bandwidth_method": str(self.bandwidth_method), + "masspoints": str(self.masspoints), + "masspoints_adjusted": bool(self.masspoints_adjusted), + "regularize": bool(self.regularize), + "n_local_min": int(self.n_local_min), + "n_unique_min": int(self.n_unique_min), + "report_all": bool(self.report_all), + } + if self.bandwidths is not None: + out["bandwidths"] = { + str(row): {str(col): float(val) for col, val in cells.items()} + for row, cells in self.bandwidths.to_dict(orient="index").items() + } + return out + def to_dataframe(self) -> pd.DataFrame: """Tidy per-side table: rows ``left``/``right``/``diff`` (plus the ``*_conventional`` rows when ``report_all=True``); columns diff --git a/docs/api/lwdid.rst b/docs/api/lwdid.rst index 248047d51..a2b9c7d8e 100644 --- a/docs/api/lwdid.rst +++ b/docs/api/lwdid.rst @@ -468,7 +468,7 @@ Example Usage # axis); this equals the eq. 7.10 unit-average estimand on balanced # never-treated designs and deviates on unbalanced panels (see the # methodology registry's within-cohort aggregation Note). - df_cohorts = results_stag.to_dataframe() + df_cohorts = results_stag.to_dataframe(level="group") print(df_cohorts) **Robustness check — demean vs detrend (informal pre-test for trend diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 874b70790..1797eabe9 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -2776,6 +2776,15 @@ Exact reviewed artifacts (PDF SHA-256) and live-verified SSRN metadata are pinne Full maintainer paper reviews (equation-level detail, replication targets): `docs/methodology/papers/lee-wooldridge-2025-review.md`, `docs/methodology/papers/lee-wooldridge-2026-review.md`. +- **Note:** 4.0 results-contract conformance (the cut's PR 1, 2026-10-18). `summary()` / + `print_summary()` take `alpha=None` under the family-wide M-146 guard (a value other than + the fit-time alpha raises; previously the methods took no alpha). `to_dataframe(level=)` + is aggregate-backed on the shared aggregation / event-study schemas (`level` in `simple` + (default), `group`, `event_study`) plus the `rolling`/`estimation_method`/`vcov_type` + columns - the 3.10 bespoke frame (`term`/`cohort` column, trailing `Overall` row, + `n_treated`/`n_control`/`n_obs`, `ci_lower`/`ci_upper`) is gone (ledger row M-153); + `to_csv(level=)` follows. The 3.10-born alias properties `pvalue`/`ci` (duplicates of + `p_value`/`conf_int`) emit a FutureWarning from 4.0 and are removed at 5.0 (M-151/M-152). - **Note:** Registry entry authored with the paper reviews ahead of the implementation (PR #588, third-party contribution under maintainer revision; MERGED 2026-08-20). The checklist boxes were finalized with the merge; the implementation-specific Notes in this section (control pools, RI convention, overall conventions, Sec 4.3 rejection, API conformance) are the maintainer finalization of that pass. - **Note:** Maintainer validation suite: `tests/test_methodology_lwdid.py` was the ACCEPTANCE BAR for PR #588's final round (passed unmodified; #588 merged 2026-08-20): it is written against the agreed post-rename API. The earlier xfail scaffolding is retired. Goldens: `benchmarks/data/lwdid_walmart_eventstudy_golden.json` (printed Tables A4/A5; point halves consumed, printed SE halves superseded), `benchmarks/data/real/castle_lw_subset.csv`, and `benchmarks/data/lwdid_stata_golden.json` (full-precision authors'-package parity, `benchmarks/stata/generate_lwdid_golden.do`; schema checked ungated by `tests/test_lwdid_stata_golden_schema.py`). - **Note:** Pre-release API-conformance decisions (locked 2026-08-15/16; no deprecation-ledger rows are owed because LWDiD never shipped on main): `estimation_method=` with house-CS values `reg`/`ipw`/`dr` plus the LWDiD-only `psm`; `vcov_type=` (name locked; accepted sets finalized in the maintainer fix wave: `reg` accepts `{classical, hc1, hc2, hc3}` — `hc3` retained as the paper-mandated extra via a shared linalg extension; `ipw`/`dr` accept `hc1` only, because the influence-function variance is always used on those paths and any other value would be silently inert; `psm` accepts `hc1` as its only configuration value while its inference is UNAVAILABLE (NaN; the Abadie-Imbens matching variance is a DEFERRED item); `cluster=` composes only with `hc1` (CR1) and is rejected for `psm`; the `"cluster"` mode value retired in favor of clustering-by-`cluster=`, `hc0`/`hc4`/`conley` not offered); `seed=` with default `None`; `pscore_trim=`; `cluster=` on the constructor; the `LW` alias, module-level `lwdid()` wrapper, `lwdid_trend_diagnostics` module, and `overall_att`/`period_effects`/`period_specific` surfaces retired (per-period effects live on the unified post-fit event-study surface). @@ -5640,6 +5649,10 @@ equal bandwidths, forbids `bwselect="each"`, and couples the plugin variance through the density-weighted combined Gram with the Psi reflection matrix per SA Lemma 13). +- **Note:** `RDDensityTestResult.to_dict()` (4.0 cut PR 1) serializes every stored scalar + with native casts (NaN inference passes through), the `*_conventional` block as `None` + unless `report_all=True`, and the bandwidth-selection table nested under `bandwidths` + only when it was estimated (a manual `h` leaves the key absent). - **Note (rank-based EDF, matches R):** the regressand is R's `Y = (0:(N-1))/(N-1)` on the sorted full sample, NOT the CJM 2020 paper's `(1/n) * sum 1[x_i <= x]` display (they differ by up to `1/N` pointwise). @@ -6036,6 +6049,10 @@ equivalently `p <= alpha`. **Implementation choices / deviations (Notes):** +- **Note:** `aggregate("simple")` (4.0 cut PR 1) relays the stored headline quintet verbatim + with `n = n_units` and a NaN `df` column: inference is bootstrap-only (centred-bootstrap + SD and symmetric `point +/- crit*se` intervals, no percentile intervals; `t_stat` is the + analytical z), so no finite df governs it. Nothing is recomputed. - **Note:** PH coefficient is the mean of usable ratios `sum alpha[t] D1[t]/D2[t]` (Theorem 1 direction, matching the authors' MATLAB/Stata code), not the paper's printed Equation 3.5 (which returns the reciprocal under exact PH) nor the diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md index abd94e173..13fbf84b6 100644 --- a/docs/migration-4.0.md +++ b/docs/migration-4.0.md @@ -23,6 +23,7 @@ updated, CI fails. | 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 | +| lwdid-results-contract | `LWDiDResults` joins the uniform results contract: the `pvalue`/`ci` alias properties warn in favour of `p_value`/`conf_int` (two rows); `to_dataframe()` is reshaped ([M-153], prose) | 2 | §7b | | 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 | @@ -239,7 +240,8 @@ weights moved to an exact non-negative least squares ([M-148], [M-149]: `Depreca at 4.0, removed at 5.0), the `covariates=` constructor-to-`fit()` move, a retired transition warning, the Bacon roster re-homing, and the family-wide `anticipation` validation below. The appendix lists the ledger-derived removals/flips; [M-144], [M-145], and [M-146] are behavior tightenings, -and [M-147] is an additive policy. These have no removal/deprecation fields and appear here only. +[M-147] is an additive policy, and [M-153] is a results-frame reshape. These have no +removal/deprecation fields and appear here only. - `WooldridgeDiD(unsupported_period_action="error")` ([M-147]) refuses periods lacking comparison support before removing them. The default `"drop"` preserves filtering and @@ -268,7 +270,8 @@ and [M-147] is an additive policy. These have no removal/deprecation fields and - `summary(alpha=...)` / `print_summary(alpha=...)` on the results classes family-wide ([M-146], landing at 4.0; the staggered family first, then the non-staggered summaries - DiDResults incl. SpilloverDiDResults, MultiPeriodDiDResults, SyntheticDiDResults, - TripleDifferenceResults, TROPResults, ContinuousDiDResults): a value different from the + TripleDifferenceResults, TROPResults, ContinuousDiDResults, and LWDiDResults - whose + `summary()` previously took no `alpha` at all): a value different from the fit-time `alpha` now raises `ValueError` instead of silently relabeling the confidence-interval header over fit-time stored intervals; `alpha=0.0`, previously swallowed by a falsy-`or` default, raises too. Re-fit at the desired alpha instead. @@ -276,6 +279,17 @@ and [M-147] is an additive policy. These have no removal/deprecation fields and dead no-op (it prints no alpha-based interval; the displayed confidence set is keyed on its own stored `gamma`), and a non-fit value now raises like the rest of the family. +- `LWDiDResults.to_dataframe()` is reshaped ([M-153], landing at 4.0): it gains the uniform + `level=` selector (`"simple"` default, `"group"`, `"event_study"`) and returns the shared + aggregation / event-study schema (`level, label, target, att, se, t_stat, p_value, + conf_int_lower, conf_int_upper, n, weight, df`) plus LWDiD's `rolling`, `estimation_method` + and `vcov_type` columns; `to_csv()` takes the same `level`. What left the 3.10-3.13 frame: + the `term` (common timing) / `cohort` (staggered) column is now `label`; the trailing + `"Overall"` row of staggered fits is now `level="simple"` (the default returns ONE headline + row - the former cohort rows are `level="group"`); `n_treated` is the schema's `n`; + `n_obs` and `n_control` are no longer in the frame (read `to_dict()`); `ci_lower`/`ci_upper` + are `conf_int_lower`/`conf_int_upper`. + The `DIFF_DIFF_SOLVE_OLS_FASTPATH` environment default was due a go/no-go at 4.0 ([M-008]): decided 2026-10-18 as **evaluated, kept off**. No numerics change — the opt-in fast path behaves exactly as in 3.x, and a later flip would be a major-version change on its own @@ -287,10 +301,13 @@ schedule. It therefore carries no appendix row. family (`attgt_weights`, `decompose_twfe_weights`, 3.13) were born on the 4.0 vocabulary, so none of the renames above touches them. The `df_convention` flip does not touch them either: they expose no such knob. `DMLDiDResults` inherits `CallawaySantAnnaResults` storage, so its -`overall_att` family rides [M-050] with the parent. `LWDiDResults` gains the uniform -`summary(alpha=None)` and `to_dataframe(level=...)` signatures in the conformance PR that -precedes the removals, which also schedules the deprecation of its `pvalue`/`ci` alias -properties (see the ledger rows added there; the aliases are silent today). +`overall_att` family rides [M-050] with the parent. `LWDiDResults` joined the uniform +results contract in the cut's conformance PR: `summary(alpha=None)` / `print_summary(alpha=None)` +under the [M-146] guard, the reshaped `to_dataframe(level=...)` / `to_csv(level=...)` described +under "Remaining 4.0 changes" ([M-153]), and FutureWarnings on its `pvalue`/`ci` alias +properties ([M-151], [M-152]: read `.p_value` / `.conf_int`; removed at 5.0). `DurationDiDResults` +gains `aggregate("simple")` (a verbatim relay of the stored bootstrap inference, `df` NaN) and +`RDDensityTestResult` gains `to_dict()`. ## Codemod @@ -349,7 +366,7 @@ but now emit a `FutureWarning`, and they no longer appear in `dir()`/`vars()`. ## Appendix: every 4.0 change -One row per ledger row that requires action at 4.0 — 111 in total. `Old` and `New` are the +One row per ledger row that requires action at 4.0 — 113 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. @@ -400,6 +417,8 @@ does not mean no action is required, so read the `Fix` cell. | 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-151 | lwdid-results-contract | `diff_diff:LWDiDResults.pvalue` | `diff_diff:LWDiDResults.p_value` | Read `.p_value`; the alias warns from 4.0 and is removed at 5.0. | +| M-152 | lwdid-results-contract | `diff_diff:LWDiDResults.ci` | `diff_diff:LWDiDResults.conf_int` | Read `.conf_int`; the alias warns from 4.0 and is removed at 5.0. | | 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. | diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index 35fb11d3c..f249c3b35 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -1783,8 +1783,8 @@ rows: status: done phase: 5 test_ref: tests/test_staggered.py - code_refs: [diff_diff/results_base.py, diff_diff/staggered_results.py, diff_diff/staggered_triple_diff_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/imputation_results.py, diff_diff/efficient_did_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/sun_abraham.py, diff_diff/results.py, diff_diff/triple_diff.py, diff_diff/trop_results.py, diff_diff/continuous_did_results.py, diff_diff/synthetic_control_results.py, docs/methodology/REGISTRY.md] - notes: "Staggered-family summary(alpha=)/print_summary(alpha=) tightening via the shared results_base._require_fit_alpha guard (the DMLDiDResults/EventStudyResults precedent): eight results classes (CallawaySantAnnaResults, StaggeredTripleDiffResults, ChaisemartinDHaultfoeuilleResults, ImputationDiDResults, EfficientDiDResults, TwoStageDiDResults, StackedDiDResults, SunAbrahamResults) previously did alpha = alpha or self.alpha and relabeled the confidence-interval header at the REQUESTED alpha while always printing the FIT-TIME stored intervals - silent coverage mislabeling on any non-fit alpha (bootstrap percentile intervals cannot be reconstructed from the SE at all). Behavior delta: accepted-and-mislabeled -> loud ValueError; alpha=0.0, previously swallowed by the falsy `or` idiom, now raises too. StaggeredTripleDiffResults is INCLUDED although its parent estimator is removed at 4.0 ([M-013]/[M-014]) - the mislabel is a live 3.x rendering bug, distinct from the estimator's frozen construction shape. Resolved - the guard is applied family-wide: the non-staggered summaries (DiDResults incl. SpilloverDiDResults by inheritance, MultiPeriodDiDResults, SyntheticDiDResults, TripleDifferenceResults, TROPResults, ContinuousDiDResults, SyntheticControlResults - the last previously a dead no-op alpha, now an honest raise) adopted it via the same helper (an optional message= override carries class-accurate wording where the staggered default's bootstrap-percentile rationale would be false; the staggered default message is byte-unchanged). Sibling behavioral evidence lives in tests/test_estimators.py, tests/test_triple_diff.py, tests/test_trop.py, tests/test_continuous_did.py, tests/test_methodology_synthetic_control.py, and tests/test_spillover.py (test_ref stays the staggered anchor: the schema's test_ref is a single path and this row remains the one logical contract). introduced_in 4.0 per the [M-144] rationale; status done is terminal." + code_refs: [diff_diff/results_base.py, diff_diff/staggered_results.py, diff_diff/staggered_triple_diff_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/imputation_results.py, diff_diff/efficient_did_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/sun_abraham.py, diff_diff/results.py, diff_diff/triple_diff.py, diff_diff/trop_results.py, diff_diff/continuous_did_results.py, diff_diff/synthetic_control_results.py, docs/methodology/REGISTRY.md, diff_diff/lwdid_results.py] + notes: "LWDiD joined the guard at the 4.0-cut PR 1 (2026-10-18): LWDiDResults.summary()/print_summary() previously took no alpha at all - they now take alpha=None under the same _require_fit_alpha contract. Staggered-family summary(alpha=)/print_summary(alpha=) tightening via the shared results_base._require_fit_alpha guard (the DMLDiDResults/EventStudyResults precedent): eight results classes (CallawaySantAnnaResults, StaggeredTripleDiffResults, ChaisemartinDHaultfoeuilleResults, ImputationDiDResults, EfficientDiDResults, TwoStageDiDResults, StackedDiDResults, SunAbrahamResults) previously did alpha = alpha or self.alpha and relabeled the confidence-interval header at the REQUESTED alpha while always printing the FIT-TIME stored intervals - silent coverage mislabeling on any non-fit alpha (bootstrap percentile intervals cannot be reconstructed from the SE at all). Behavior delta: accepted-and-mislabeled -> loud ValueError; alpha=0.0, previously swallowed by the falsy `or` idiom, now raises too. StaggeredTripleDiffResults is INCLUDED although its parent estimator is removed at 4.0 ([M-013]/[M-014]) - the mislabel is a live 3.x rendering bug, distinct from the estimator's frozen construction shape. Resolved - the guard is applied family-wide: the non-staggered summaries (DiDResults incl. SpilloverDiDResults by inheritance, MultiPeriodDiDResults, SyntheticDiDResults, TripleDifferenceResults, TROPResults, ContinuousDiDResults, SyntheticControlResults - the last previously a dead no-op alpha, now an honest raise) adopted it via the same helper (an optional message= override carries class-accurate wording where the staggered default's bootstrap-percentile rationale would be false; the staggered default message is byte-unchanged). Sibling behavioral evidence lives in tests/test_estimators.py, tests/test_triple_diff.py, tests/test_trop.py, tests/test_continuous_did.py, tests/test_methodology_synthetic_control.py, and tests/test_spillover.py (test_ref stays the staggered anchor: the schema's test_ref is a single path and this row remains the one logical contract). introduced_in 4.0 per the [M-144] rationale; status done is terminal." - id: M-147 kind: behavior group: etwfe-reference-period @@ -1839,3 +1839,45 @@ rows: test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/wooldridge_results.py] notes: "Locator-completeness amendment at the 4.0-cut framing: WooldridgeDiDResults.to_dataframe(level='event') has warned since 3.9 (the [M-044] rename PR), but only [M-086]'s notes recorded it - the matrix had no locator for this third surface of the 'event' -> 'event_study' value unification (aggregate(type=) is [M-086]; summary(aggregation=) is [M-087]). Twin of [M-136] (LPDiD's to_dataframe level value). test_ref: test_to_dataframe_level_rename in tests/test_v4_rename_semantic.py." + # ---- PR 1: new-estimator conformance (2026-10-18) --------------------- + - id: M-151 + kind: field + group: lwdid-results-contract + old: "diff_diff:LWDiDResults.pvalue" + new: "diff_diff:LWDiDResults.p_value" + introduced_in: null + deprecated_in: "4.0" + removed_in: "5.0" + status: shimmed + phase: 5 + warning: FutureWarning + test_ref: tests/test_lwdid.py + code_refs: [diff_diff/lwdid_results.py, diff_diff/_deprecation.py, docs/methodology/REGISTRY.md] + notes: "Born 3.10 as an undocumented duplicate of the canonical quintet name p_value (the alias-diet class, spec section 3.2: one name per concept) - no reader anywhere in the repo (every .pvalue hit is RandomizationResult / SpecificationResult / the lwdid-py package, independent surfaces). Deprecated AT 4.0 rather than removed, because the name shipped in released minors 3.10-3.13; removed 5.0. Shim = deprecated_field_property(..., removed_in='5.0') so the warning and the generated docstring name 5.0 (the helper gained the removed_in keyword in this PR). Row added in the same PR as the shim (the [M-148]/[M-149] precedent). ENFORCEMENT NOTE: 'pvalue' joins test_naming_guard's _PATTERN_TOKENS and _AMBIGUOUS_TOKENS (the two kwarg-lane hits are the independent same-named fields on RandomizationResult / SpecificationResult, allowlisted)." + - id: M-152 + kind: field + group: lwdid-results-contract + old: "diff_diff:LWDiDResults.ci" + new: "diff_diff:LWDiDResults.conf_int" + introduced_in: null + deprecated_in: "4.0" + removed_in: "5.0" + status: shimmed + phase: 5 + warning: FutureWarning + test_ref: tests/test_lwdid.py + code_refs: [diff_diff/lwdid_results.py, diff_diff/_deprecation.py, docs/methodology/REGISTRY.md] + notes: "Twin of [M-151] for the conf_int alias; same window, same shim. ENFORCEMENT NOTE: 'ci' is library-wide local vocabulary (`ci = ...` assignments in ~21 source modules) and RDPlot's own confidence-level parameter, so it joins _AMBIGUOUS_TOKENS (precise lanes only) with a SURFACE_ALLOWLIST entry for RDPlot.__init__[ci] and CONSUMER_ALLOWLIST entries for rdplot.py and the CCFT-2017 paper review; REGISTRY.md's rdplot `ci` backtick is covered by this row's code_refs." + - id: M-153 + kind: behavior + group: lwdid-results-contract + old: "diff_diff:LWDiDResults.to_dataframe[level]" + new: null + introduced_in: "4.0" + deprecated_in: null + removed_in: null + status: done + phase: 5 + test_ref: tests/test_lwdid.py + code_refs: [diff_diff/lwdid_results.py, docs/methodology/REGISTRY.md, docs/api/lwdid.rst] + notes: "LWDiDResults.to_dataframe() reshaped onto the uniform results contract at the 4.0 cut (spec section 5: to_dataframe's selector is level= everywhere): level in {'simple', 'event_study', 'group'} (the class's aggregate() vocabulary), each returning aggregate(level).to_dataframe() - the shared AGGREGATION_SCHEMA / EVENT_STUDY_SCHEMA - plus the three LWDiD config columns rolling / estimation_method / vcov_type; default 'simple'; to_csv() relays the same level. The 3.10-3.13 frame had no selector and a bespoke shape: a term (common timing) / cohort (staggered) column -> now label; a trailing 'Overall' row on staggered fits -> now level='simple'; n_treated -> the schema's n; n_obs and n_control -> no longer in the frame (to_dict() keeps both); ci_lower/ci_upper -> conf_int_lower/conf_int_upper. A 3.10-born frame reshaped at the major, so no deprecation window - the [M-144]..[M-147] post-cut behavior shape (introduced_in 4.0, done). Documented in the migration guide's prose (not the ledger-derived appendix)." diff --git a/docs/v4-design.md b/docs/v4-design.md index fe9d6ceb2..a643a2f64 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -111,7 +111,7 @@ roster only.) | ChangesInChanges | Keep + absorbs QDiD via `method=` [M-015] | | QDiD | Removed 4.0 [M-015] | | SyntheticDiD, SyntheticControl, TROP, ContinuousDiD, HeterogeneousAdoptionDiD, RegressionDiscontinuity, SpilloverDiD, ChaisemartinDHaultfoeuille | Keep | -| LWDiD (added 3.10), DMLDiD (added 3.11), DurationDiD (added 3.12) | Keep - born on the 4.0 vocabulary (no rename rows); LWDiD's results-contract gaps are PR-1 work (section 5) | +| LWDiD (added 3.10), DMLDiD (added 3.11), DurationDiD (added 3.12) | Keep - born on the 4.0 vocabulary (no rename rows); LWDiD's results-contract gaps were closed by the cut's PR 1 [M-151]..[M-153] (section 5) | | BaconDecomposition | Keep - moves into the diagnostic family (section 3.5) | ### 3.2 Final alias table @@ -474,12 +474,17 @@ ESTIMATOR result class already exposes the full quintet as properties (verified 2026-07-18), so 3.9 needs no additive property work. (2026-10-18: `LWDiDResults` and `DurationDiDResults` were born with the canonical quintet as NATIVE fields - no flip rows; `DMLDiDResults` inherits -`CallawaySantAnnaResults` storage and rides [M-050]. `LWDiDResults` deviates -from the uniform signatures - `summary()` lacks `alpha=None`, -`to_dataframe()` lacks the `level=` selector - and carries `pvalue`/`ci` -alias properties for `p_value`/`conf_int`; the signatures are added and the -aliases rowed for deprecation in the cut sequence's PR 1, section 9, on the -[M-148]/[M-149] same-PR-as-the-shim precedent.) At 4.0 the STORAGE flips [M-050]..[M-058]: the canonical names +`CallawaySantAnnaResults` storage and rides [M-050]. `LWDiDResults` deviated +from the uniform signatures - `summary()` lacked `alpha=None`, +`to_dataframe()` lacked the `level=` selector - and carried `pvalue`/`ci` +alias properties for `p_value`/`conf_int`; the cut's PR 1 (section 9) added +the signatures (`summary(alpha=None)` under the [M-146] guard; +`to_dataframe(level=)` aggregate-backed on the shared schemas plus the +LWDiD config columns, default `"simple"` - a reshape of the 3.10-born +bespoke frame, rowed as the behavior row [M-153]) and deprecated the +aliases [M-151] [M-152] on the [M-148]/[M-149] same-PR-as-the-shim +precedent, with a 4.0 -> 5.0 window because the names shipped in released +minors.) 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 @@ -936,7 +941,7 @@ above; anything only one PR cares about stays in that PR's plan.** | 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] [M-139] (M-020's shim already shipped; M-139 is the HAD workflow twin, a pre-cut amendment); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + the 2(c)-ii missed-rename amendments [M-136..M-138] (LPDiD `level` value; the two post-dummy diagnostics params) + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] | | 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] + the fit `time`->`post` rename [M-082] (gates: section 4.1's equivalence/divergence/pooled-parity test triple) (shipped: tests/test_v4_merge_mpd.py; consumer ports incl. HonestDiD/PreTrendsPower calendar routes); (b) TripleDifference facade [M-013] + the SDDD alias [M-064] (shipped: tests/test_v4_merge_ddd.py; the engine relocation into the private shared mixin, the keyword-only staggered fit params, and the pscore_trim tightening [M-142]. The fit-time aggregate=/balance_e= carve-out rows are scheduled REMOVALS and so are cited in the phase-5 cell, not here - their only lifecycle version is removed_in 4.0); (c) CiC method= [M-015] + its results-field mirror [M-143] (shipped: tests/test_v4_merge_cic.py; method= is keyword-only and lowercase-only, the QDiD CLASS is deprecated while the METHOD is not, and ChangesInChangesResults.estimator -> .method carries a dual-key to_dict() window through 3.9) | | 4: release + soak | 3.9 cut | Migration guide written (skeleton: section 10); maintainer cuts 3.9; maint/3.8 rule active | -| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-139, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120, M-140, M-141, M-143] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] [M-150] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; the family-wide anticipation validation [M-144], the family-wide pscore_trim validation [M-145], and the family-wide summary-alpha guard [M-146] (staggered family at the initial cut; extended to the non-staggered summaries post-cut) (behavior tightenings landing at 4.0 - shipped post-3.9-cut, terminal `done`, no removal/deprecation fields); the additive Wooldridge comparison-support policy [M-147] (`unsupported_period_action="drop"` preserves filtering, `"error"` refuses before period removal; same post-cut lifecycle); the inert SyntheticControl inner-budget pair [M-148] [M-149] (warn-and-discard shims at 4.0 once the donor weights moved to an exact NNLS, removal at 5.0); docs/llms.txt/README refresh | +| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-139, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120, M-140, M-141, M-143] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] [M-150] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; the family-wide anticipation validation [M-144], the family-wide pscore_trim validation [M-145], and the family-wide summary-alpha guard [M-146] (staggered family at the initial cut; extended to the non-staggered summaries post-cut), the LWDiD to_dataframe reshape [M-153] (behavior tightenings landing at 4.0 - shipped post-3.9-cut, terminal `done`, no removal/deprecation fields); the additive Wooldridge comparison-support policy [M-147] (`unsupported_period_action="drop"` preserves filtering, `"error"` refuses before period removal; same post-cut lifecycle); the inert SyntheticControl inner-budget pair [M-148] [M-149] (warn-and-discard shims at 4.0 once the donor weights moved to an exact NNLS, removal at 5.0); the LWDiD result-alias pair [M-151] [M-152] (FutureWarning shims at 4.0, removal at 5.0); docs/llms.txt/README refresh | | 6: front door | 4.1 | `event_study(data, outcome, unit, time, first_treat, estimator=...)` comparison entry point over the staggered family (sketch only; specified in its own plan) | Citation semantic for the table: a cell may cite a row whose current `phase` @@ -1001,10 +1006,11 @@ its rows terminal and names them in its changelog fragment. 0. Spec/ledger refresh (the PR that wrote this subsection): the cut-window rule, the roster/M-080/M-008 amendments, the [M-150] locator, the id snapshot repair, and the `maint/3.13` branch cut. -1. New-estimator conformance (additive): `LWDiDResults.summary(alpha=None)` / - `to_dataframe(level=)`, rows for its `pvalue`/`ci` aliases (lifecycle in +1. New-estimator conformance - additive signatures plus ONE schema reshape: + `LWDiDResults.summary(alpha=None)`, `to_dataframe(level=)` (the reshape, + [M-153]), rows for its `pvalue`/`ci` aliases [M-151] [M-152] (lifecycle in the rows, on the [M-148]/[M-149] same-PR precedent), DurationDiD - `aggregate("simple")`, `RDDensityTestResult.to_dict()`. + `aggregate("simple")`, `RDDensityTestResult.to_dict()`. Shipped. 2. Mechanical shim removals: the rename rows, the wrapper functions, the alias diet, the SyntheticDiD params, the legacy-CR1 remap branches named on [M-045]/[M-115]; `_deprecation.py` stays (section 8 rule 11 corollary). @@ -1250,7 +1256,8 @@ forever - a removed symbol resurrecting is a test failure. class/function rows and alias rows also assert `__all__` membership consistent with their status (stale `import *` entries fail). The shipped row ids are a - committed snapshot in the enforcement test (132 as of the 4.0-cut framing - + committed snapshot in the enforcement test (135 as of the 4.0-cut PR 1, + which added M-151..M-153; 132 as of the 4.0-cut framing - M-148/M-149 were shipped by #838 without extending the snapshot, so the framing PR added them together with M-150; 129 as of the ETWFE unsupported-period policy; previously 126 as of the family-wide @@ -1261,7 +1268,7 @@ forever - a removed symbol resurrecting is a test failure. reference-period pair M-123/M-124 + M-125 + M-126 + M-127..M-131 + the alias-diet family M-132..M-135 + the 2(c)-ii amendments M-136..M-138 + M-139 + the DDD-merge rows M-140..M-142 + the CiC - results-field mirror M-143 + the anticipation policy row M-144 + the DML review-follow-ups pair M-145/M-146 + the unsupported-period policy M-147 + the SyntheticControl inner-budget pair M-148/M-149 + the Wooldridge to_dataframe level twin M-150; + results-field mirror M-143 + the anticipation policy row M-144 + the DML review-follow-ups pair M-145/M-146 + the unsupported-period policy M-147 + the SyntheticControl inner-budget pair M-148/M-149 + the Wooldridge to_dataframe level twin M-150 + the LWDiD results-contract rows M-151..M-153; the snapshot extends by a new id range in the same diff that appends rows): ids are never deleted or reused, and the test fails if any snapshot id disappears. diff --git a/tests/test_duration_did.py b/tests/test_duration_did.py index d5f3e196f..23fc69e53 100644 --- a/tests/test_duration_did.py +++ b/tests/test_duration_did.py @@ -1070,7 +1070,27 @@ def test_event_study_aggregate(self, fitted): with pytest.raises(ValueError, match="weights"): fitted.aggregate("event_study", weights="cell") with pytest.raises(ValueError, match="Unsupported aggregation type"): - fitted.aggregate("simple") + fitted.aggregate("group") + + def test_simple_aggregate_relays_stored_inference(self, fitted): + agg = fitted.aggregate("simple") + assert agg.level == "simple" and list(agg.label) == ["overall"] + assert list(agg.target) == ["att"] and agg.estimator == "DurationDiD" + assert agg.att[0] == fitted.att and agg.se[0] == fitted.se + assert agg.t_stat[0] == fitted.t_stat and agg.p_value[0] == fitted.p_value + assert (agg.conf_int_lower[0], agg.conf_int_upper[0]) == tuple(fitted.conf_int) + assert agg.n[0] == fitted.n_units and agg.n_kind == "units" + assert np.isnan(agg.df[0]) # bootstrap-only inference: no finite df governs it + assert agg.alpha == fitted.alpha + frame = agg.to_dataframe() + assert len(frame) == 1 and frame.loc[0, "att"] == fitted.att + + def test_simple_aggregate_without_inference(self): + r = fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3) + agg = r.aggregate("simple") + assert np.isfinite(agg.att[0]) + for name in ("se", "t_stat", "p_value", "conf_int_lower", "conf_int_upper", "df"): + assert np.isnan(getattr(agg, name)[0]), name def test_event_study_without_inference(self): r = fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3) diff --git a/tests/test_lwdid.py b/tests/test_lwdid.py index 9b6fbd051..8b27412fe 100644 --- a/tests/test_lwdid.py +++ b/tests/test_lwdid.py @@ -4636,3 +4636,139 @@ def test_cohort_effect_cell_mass_oracle_unbalanced(self): # unit-average value (3.5). The two surfaces answer different, # separately documented estimands on unbalanced panels. np.testing.assert_allclose(res.att, 3.5, atol=1e-10) + + +class TestV4ResultsContract: + """4.0 results-contract conformance (ledger rows M-146, M-151, M-152, M-153).""" + + @staticmethod + def _common_df(seed=23, n=40, T=6): + rng = np.random.default_rng(seed) + rows = [] + for u in range(n): + treated_unit = int(u < n // 2) + alpha = rng.normal() + for t in range(1, T + 1): + d = int(treated_unit and t >= 4) + rows.append(dict(unit=u, time=t, treat=d, y=alpha + rng.normal() + 0.8 * d)) + return pd.DataFrame(rows) + + @staticmethod + def _staggered_df(seed=29, n=30, T=6): + rng = np.random.default_rng(seed) + rows = [] + for u in range(n): + g = 3 if u < 10 else (4 if u < 20 else 0) + alpha = rng.normal() + for t in range(1, T + 1): + d = int(g > 0 and t >= g) + rows.append(dict(unit=u, time=t, first=g, treat=d, y=alpha + rng.normal() + d)) + return pd.DataFrame(rows) + + @pytest.fixture(scope="class") + def common(self): + df = self._common_df() + return LWDiD().fit(df, outcome="y", unit="unit", time="time", treatment="treat") + + @pytest.fixture(scope="class") + def staggered(self): + df = self._staggered_df() + return LWDiD().fit( + df, outcome="y", unit="unit", time="time", treatment="treat", first_treat="first" + ) + + # -- M-146: summary(alpha=) joins the family-wide guard ------------------- + def test_summary_alpha_matching_fit_is_identity(self, common): + assert common.summary(alpha=common.alpha) == common.summary() + + @pytest.mark.parametrize("bad_alpha", [0.10, 0.0]) + def test_summary_alpha_mismatch_raises(self, common, bad_alpha): + with pytest.raises(ValueError, match="never recomputes"): + common.summary(alpha=bad_alpha) + with pytest.raises(ValueError, match="never recomputes"): + common.print_summary(alpha=bad_alpha) + + # -- M-153: to_dataframe(level=) is aggregate-backed ---------------------- + def test_to_dataframe_default_is_simple_relay_plus_config_columns(self, common): + from diff_diff.aggregation import AGGREGATION_SCHEMA + + frame = common.to_dataframe() + pd.testing.assert_frame_equal(frame, common.to_dataframe(level="simple")) + expected = common.aggregate("simple").to_dataframe() + assert len(frame) == 1 + assert list(frame.columns) == list(AGGREGATION_SCHEMA) + [ + "rolling", + "estimation_method", + "vcov_type", + ] + pd.testing.assert_frame_equal(frame[list(AGGREGATION_SCHEMA)], expected) + assert frame.loc[0, "att"] == common.att + assert frame.loc[0, "conf_int_lower"] == common.conf_int[0] + assert frame.loc[0, "conf_int_upper"] == common.conf_int[1] + assert frame.loc[0, "n"] == common.n_treated + for old in ("ci_lower", "ci_upper", "term", "cohort", "n_obs", "n_control", "n_treated"): + assert old not in frame.columns + assert frame.loc[0, "rolling"] == common.rolling + assert frame.loc[0, "vcov_type"] == common.vcov_type + + def test_to_dataframe_group_level(self, staggered, common): + frame = staggered.to_dataframe(level="group") + assert len(frame) == len(staggered.cohort_effects) + assert set(frame["label"]) == set(staggered.cohort_effects) + assert "Overall" not in set(frame["label"]) + assert (frame["level"] == "group").all() + with pytest.raises(ValueError): + common.to_dataframe(level="group") + + def test_to_dataframe_event_study_level(self, staggered): + from diff_diff.results_base import EVENT_STUDY_SCHEMA + + frame = staggered.to_dataframe(level="event_study") + for col in EVENT_STUDY_SCHEMA: + assert col in frame.columns + for col in ("rolling", "estimation_method", "vcov_type"): + assert col in frame.columns and frame[col].nunique() == 1 + assert frame["is_reference"].any() + + def test_to_dataframe_unknown_level_raises(self, common): + with pytest.raises(ValueError, match="Unknown level"): + common.to_dataframe(level="cohort") + + def test_to_csv_relays_level(self, staggered, tmp_path): + path = tmp_path / "cohorts.csv" + staggered.to_csv(str(path), level="group") + back = pd.read_csv(path) + assert len(back) == len(staggered.cohort_effects) + assert "conf_int_lower" in back.columns and "ci_lower" not in back.columns + staggered.to_csv(str(tmp_path / "headline.csv")) + assert len(pd.read_csv(tmp_path / "headline.csv")) == 1 + + def test_to_dataframe_relays_nan_inference_jointly(self): + df = self._common_df(seed=31) + df["x"] = (df["unit"] % 4).astype(float) # unit-constant covariate for the PSM + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = LWDiD(estimation_method="psm").fit( + df, outcome="y", unit="unit", time="time", treatment="treat", covariates=["x"] + ) + assert np.isnan(res.se) # psm: inference unavailable, att retained + frame = res.to_dataframe() + assert np.isfinite(frame.loc[0, "att"]) + for col in ("se", "t_stat", "p_value", "conf_int_lower", "conf_int_upper", "df"): + assert np.isnan(frame.loc[0, col]), col + + # -- M-151 / M-152: pvalue / ci alias shims ------------------------------- + @pytest.mark.parametrize("old, new", [("pvalue", "p_value"), ("ci", "conf_int")]) + def test_alias_properties_warn_and_relay(self, common, old, new): + from diff_diff.lwdid_results import LWDiDResults + + assert old not in LWDiDResults.__dataclass_fields__ + assert new in LWDiDResults.__dataclass_fields__ + with pytest.warns( + FutureWarning, match=rf"LWDiDResults\.{old} is deprecated.*removed in 5\.0" + ): + value = getattr(common, old) + assert value == getattr(common, new) + with pytest.raises(AttributeError): + setattr(common, old, value) + assert "removed in 5.0" in getattr(LWDiDResults, old).__doc__ diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index 8716ce535..23c7f6b50 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -146,6 +146,10 @@ # since the exact NNLS inner solve; warn-and-discard shims at 4.0). "inner_max_iter", "inner_min_decrease", + # M-151 / M-152 (LWDiDResults.pvalue -> .p_value, .ci -> .conf_int; 3.10-born + # alias properties deprecated at 4.0, removed 5.0). + "pvalue", + "ci", } # Tokens that are LEGAL canonical vocabulary on other surfaces (rule 1 `time`, @@ -172,6 +176,13 @@ # only kwarg-lane hits left in changes_in_changes.py are the construction # site (now method=) and validate_covariate_names', neither a stale reader. "estimator", + # M-152's old token: `ci` is library-wide LOCAL vocabulary (`ci = ...` + # assignments in ~21 source modules) and RDPlot's own confidence-level + # parameter, so only the precise lanes may match it. M-151's `pvalue` is + # the same shape: the kwarg-lane hits are the independent same-named + # fields on RandomizationResult / SpecificationResult. + "ci", + "pvalue", } @@ -537,6 +548,13 @@ def _build_rowed_index(): ) SURFACE_ALLOWLIST = { + # M-152 deprecates LWDiDResults.ci (-> conf_int). RDPlot's `ci` is the + # rdplot confidence-level parameter (R rdplot's `ci`), an independent + # surface that shares only the word. + "RDPlot.__init__[ci]": ( + "rdplot's confidence-level parameter (R rdplot `ci`), an independent " + "surface - not the LWDiD results alias (M-152)" + ), # M-143 renames a RESULTS field named `estimator`; these four public # surfaces share only the word. AggregationResult.estimator holds a CLASS # NAME ("StackedDiD"), not a method tag - the very collision M-143 exists @@ -1005,6 +1023,31 @@ def _token_family_code_refs(tok): ) CONSUMER_ALLOWLIST = { + # M-151 / M-152: the dying LWDiDResults aliases share their words with + # independent surfaces - RDPlot's `ci` attribute and the rdplot `ci` + # parameter table in the CCFT-2017 review; RandomizationResult.pvalue and + # SpecificationResult.pvalue (their own fields); and lwdid.py's docstring + # table mapping lwdid-py's `result.pvalue` onto diff-diff's `p_value`, + # which documents the OTHER package, not a reader of the alias. + ( + "ci", + "diff_diff/rdplot.py", + ): "RDPlot's own `ci` attribute (confidence level), not the LWDiD alias", + ("ci", "docs/methodology/papers/calonico-cattaneo-farrell-titiunik-2017-review.md"): ( + "rdplot `ci` parameter table, not the LWDiD alias" + ), + ( + "pvalue", + "diff_diff/lwdid_randomization.py", + ): "RandomizationResult.pvalue, an independent field", + ( + "pvalue", + "diff_diff/lwdid_sensitivity.py", + ): "SpecificationResult.pvalue, an independent field", + ("pvalue", "diff_diff/lwdid.py"): ( + "lwdid-py -> diff-diff name-mapping docstring table documents the other package's " + "`result.pvalue`; not a reader of the alias" + ), # M-143's `estimator` token is ordinary vocabulary across the library: # report schema keys, an AggregationResult field holding a class name, and # a label parameter. None of these reads ChangesInChangesResults' renamed diff --git a/tests/test_rddensity.py b/tests/test_rddensity.py index fcf6eaaf1..83e2d0e22 100644 --- a/tests/test_rddensity.py +++ b/tests/test_rddensity.py @@ -1046,6 +1046,65 @@ def test_summary_behavioral(self, fitted): res_all = _fit_quiet(RDDensityTest(report_all=True), rng.normal(size=900)) assert "Conventional" in res_all.summary() + def test_to_dict_json_round_trip(self, fitted): + d = fitted.to_dict() + json.dumps(d) # NaN-free here; stdlib emits NaN as NaN when present + assert d["t_stat"] == fitted.t_stat and d["p_value"] == fitted.p_value + assert d["n"] == fitted.n and isinstance(d["n"], int) + assert d["h_left"] == fitted.h_left and isinstance(d["h_left"], float) + assert d["f_left_conventional"] is None # report_all=False + assert list(d["bandwidths"]) == ["left", "right", "diff", "sum"] + assert set(d["bandwidths"]["left"]) == {"bw", "variance", "bias_sq"} + assert d["bandwidths"]["diff"]["bw"] == fitted.bandwidths.loc["diff", "bw"] + for key in ("att", "se", "conf_int"): + assert key not in d + frame = fitted.to_dataframe() + assert d["f_diff"] == frame.loc["diff", "estimate"] + + rng = np.random.default_rng(97) + manual = _fit_quiet(RDDensityTest(h=0.7), rng.normal(size=900)).to_dict() + assert "bandwidths" not in manual + res_all = _fit_quiet(RDDensityTest(report_all=True), rng.normal(size=900)) + d_all = res_all.to_dict() + assert d_all["report_all"] is True + assert d_all["f_left_conventional"] == res_all.f_left_conventional + assert isinstance(d_all["t_stat_conventional"], float) + + def test_to_dict_degraded_inference_round_trips_nan(self): + from unittest import mock + + import diff_diff.rddensity as mod + + rng = np.random.default_rng(101) + x = rng.normal(size=800) + real_fv = mod._rddensity_fv + + def wrecked(*args, **kwargs): + out = real_fv(*args, **kwargs) + out[:, 1] = np.nan + return out + + with mock.patch.object(mod, "_rddensity_fv", wrecked): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = RDDensityTest(h=0.8, report_all=True).fit(pd.DataFrame({"x": x}), running="x") + d = res.to_dict() + json.dumps(d) + assert np.isfinite(d["f_diff"]) and np.isfinite(d["f_diff_conventional"]) + for key in ( + "se_left", + "se_right", + "se_diff", + "t_stat", + "p_value", + "se_left_conventional", + "se_right_conventional", + "se_diff_conventional", + "t_stat_conventional", + "p_value_conventional", + ): + assert isinstance(d[key], float) and np.isnan(d[key]), key + def test_summary_q_equals_p_labels_conventional(self): # R labels the headline "Robust" even when q == p (the conventional # test) and with all=TRUE prints the identical statistic twice; the diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py index aac57233f..64fd77d9c 100644 --- a/tests/test_v4_matrix.py +++ b/tests/test_v4_matrix.py @@ -140,11 +140,13 @@ # family-wide summary-alpha guard) = 128; + the ETWFE unsupported-period # policy (M-147) = 129; + the SyntheticControl inner-budget pair (M-148/M-149, # shipped by #838 without extending this floor) = 131; + the Wooldridge -# to_dataframe level twin (M-150, 4.0-cut framing) = 132. +# to_dataframe level twin (M-150, 4.0-cut framing) = 132; + the LWDiD +# results-contract rows (M-151/M-152 alias deprecations, M-153 to_dataframe +# reshape; 4.0-cut PR 1) = 135. # 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 = 132 +ROW_COUNT_FLOOR = 135 # Committed snapshot of the shipped id set ("ids are never deleted or reused" # contract - a delete-one-add-one edit keeps the count above the floor but trips @@ -203,6 +205,7 @@ (147, 147), (148, 149), (150, 150), + (151, 153), ] EXPECTED_INITIAL_IDS = frozenset( f"M-{n:03d}" for lo, hi in _INITIAL_ID_RANGES for n in range(lo, hi + 1) @@ -690,10 +693,11 @@ def test_initial_ids_never_deleted(): M-123/M-124 + M-125 + M-126 + M-127..M-131 + M-132..M-135 + M-136..M-138 + M-139 + M-140..M-142 + M-143 + M-144 + M-145/M-146 + M-147 + M-148/M-149 (shipped by #838 without extending the snapshot; added at the - 4.0-cut framing) + M-150).""" + 4.0-cut framing) + M-150 + M-151..M-153 (the LWDiD results-contract rows, + 4.0-cut PR 1)).""" missing = sorted(EXPECTED_INITIAL_IDS - set(_ROW_IDS)) assert not missing, f"ledger rows deleted (ids are permanent): {missing}" - assert len(EXPECTED_INITIAL_IDS) == 132 + assert len(EXPECTED_INITIAL_IDS) == 135 def test_version_tuple_pads_to_three_components(): @@ -1323,18 +1327,18 @@ def _changes_at_4_0(row): Keyed on the two LIFECYCLE version fields only - a symbol removed at 4.0, or one whose deprecation warning starts firing at 4.0 (the ``field-flip`` family, removed - at 5.0). 111 of the 132 rows qualify. + at 5.0). 113 of the 135 rows qualify. - The 21 that do not, and why (this enumeration is the contract - a reader of the + The 22 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. - - 4 ``behavior`` rows with ``introduced_in: 4.0`` and no deprecation/removal + - 5 ``behavior`` rows with ``introduced_in: 4.0`` and no deprecation/removal fields (``M-144`` anticipation validation, ``M-145`` pscore_trim validation, - ``M-146`` summary-alpha guard, ``M-147`` ETWFE unsupported-period policy - - the post-cut behavior changes): they land + ``M-146`` summary-alpha guard, ``M-147`` ETWFE unsupported-period policy, + ``M-153`` LWDiD to_dataframe reshape - the post-cut behavior changes): they land AT 4.0 but remove/deprecate nothing, so they appear in the guide's "Remaining 4.0 changes" prose, not the ledger-derived appendix. - ``M-062``, ``M-063``: aliases, introduce-only / all lifecycle fields null. diff --git a/tests/test_v4_rename_mechanical.py b/tests/test_v4_rename_mechanical.py index da7c4a8f9..23f094a2e 100644 --- a/tests/test_v4_rename_mechanical.py +++ b/tests/test_v4_rename_mechanical.py @@ -124,6 +124,23 @@ class Toy: assert toy.old_name == 3 with pytest.raises(AttributeError): toy.old_name = 5 + # default removal version is the 3.9 sweep's 4.0, in both the warning and the docstring + with pytest.warns(FutureWarning, match=r"Will be removed in 4\.0\."): + toy.old_name + assert "(removed in 4.0)" in Toy.old_name.__doc__ + + def test_deprecated_field_property_removed_in_override(self): + """Aliases deprecated AT 4.0 (M-151/M-152) name their 5.0 removal in both surfaces.""" + + @dataclass + class Toy: + conf_int: int = 7 + ci = deprecated_field_property("Toy", "ci", "conf_int", removed_in="5.0") + + toy = Toy() + with pytest.warns(FutureWarning, match=r"Toy\.ci is deprecated.*Will be removed in 5\.0\."): + assert toy.ci == 7 + assert Toy.ci.__doc__ == "Deprecated alias for :attr:`conf_int` (removed in 5.0)." # ---------------------------------------------------------------------------