Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Lint

# Check-only lint enforcement (ruff + black). This workflow never modifies
# code — it reads the tree and passes or fails.
# Check-only lint + type enforcement (ruff + black + mypy). This workflow
# never modifies code — it reads the tree and passes or fails.
#
# Deliberately UNGATED (no ready-for-ci label check; precedent:
# ai_pr_review.yml): these are cheap static checks that should give feedback
Expand Down Expand Up @@ -45,15 +45,33 @@ jobs:
- name: Black
run: black --check diff_diff tests

typecheck:
name: Mypy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.14'
# Runtime deps MUST be installed: with ignore_missing_imports=true an
# absent numpy silently becomes Any and most errors vanish — CI would
# go green while local runs stay red. Pinning them freezes stub drift.
# The package itself is NOT installed (mypy analyzes source directly;
# no maturin/Rust build ever enters this workflow).
- name: Install mypy + runtime deps for their type stubs (pinned)
run: pip install mypy==2.1.0 numpy==2.4.5 pandas==3.0.3 scipy==1.17.1
- name: Mypy
run: mypy diff_diff

# Single stable required-check name so branch protection is configured once.
# Later additions (e.g. a mypy job) extend `needs` without any protection
# change. `if: always()` is load-bearing: without it a failed upstream job
# would leave this job SKIPPED, and GitHub treats a skipped required check
# as satisfied.
# New jobs extend `needs` without any protection change. `if: always()` is
# load-bearing: without it a failed upstream job would leave this job
# SKIPPED, and GitHub treats a skipped required check as satisfied.
lint-gate:
name: Lint Gate
runs-on: ubuntu-latest
needs: [format-lint]
needs: [format-lint, typecheck]
if: always()
steps:
- name: All lint jobs must succeed
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Internal: mypy enforced at zero errors.** Triaged the 184 pre-existing
`mypy diff_diff` errors to an enforceable zero and added a blocking Mypy job to
the Lint CI workflow (pinned `mypy==2.1.0` + pinned numpy/pandas/scipy for stub
stability; the package itself is not installed). Real fixes include: 19 survey
parameters typed `object` across 13 estimator files (now properly typed via
`TYPE_CHECKING` imports), four stale return-arity annotations, a helper that
broke the `solve_ols` overload chain, a float-keyed diagnostics dict annotated
str-keyed, duplicate conflicting local annotations, a missing bootstrap-mixin
attribute stub, and ~50 explicit `assert ... is not None` narrowings at
flag-guarded blocks (documenting real invariants). Residual noise is contained
by documented suppressions (`prep_dgp` per-module `[index]` override,
`matplotlib`/`plotly` `follow_imports = "skip"`, a handful of reasoned inline
ignores) with `warn_unused_ignores = true` to keep them from going stale — 23
already-stale ignores were removed. Tightening tracked in TODO.md. No public
API or numerical behavior change.
- **Internal: rdrobust sharp-RD bandwidth-selection port (`diff_diff/_rdrobust_port.py`).**
Step-1 machinery for the upcoming `RegressionDiscontinuity` estimator: a faithful
pure-Python NumPy/SciPy port of R `rdrobust` 4.0.0's `rdbwselect` sharp-RD path (all 10 data-driven
Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ mypy diff_diff
```

Lint/format/type tool versions are **pinned exactly** in the `dev` extra of
`pyproject.toml`; the `ruff`/`black` pins are mirrored in
`.github/workflows/lint.yml` (the ungated `Lint Gate` check runs `ruff check`
+ `black --check` on every PR push; sync enforced by `TestLintWorkflowPinSync`),
while `mypy` is pinned in `pyproject.toml` only until the type-check CI job
lands — update both surfaces together on any bump. Refresh local tools with `pip install -e ".[dev]"`
`pyproject.toml` and mirrored in `.github/workflows/lint.yml` (the ungated
`Lint Gate` check runs `ruff check` + `black --check` + `mypy diff_diff` on
every PR push; sync enforced by `TestLintWorkflowPinSync`) — update both
surfaces together on any bump. Mypy is enforced at ZERO errors; new code
must type-check cleanly. Refresh local tools with `pip install -e ".[dev]"`
(the pinned tools need Python >= 3.10; the library floor stays 3.9). Some
ruff rules are deliberately ignored per-file (`[tool.ruff.lint.per-file-ignores]`)
— don't "fix" those patterns. One-time setup so `git blame` skips the 2026-07
Expand Down
15 changes: 8 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ push, and its `Lint Gate` job is a required status check:
```bash
ruff check diff_diff tests
black diff_diff tests
mypy diff_diff
```

- Tool versions are **pinned exactly** in the `dev` extra of `pyproject.toml`
(`black`, `ruff`, `mypy`). The Lint workflow currently mirrors the `ruff`
and `black` pins (`.github/workflows/lint.yml`; the sync is enforced by
`TestLintWorkflowPinSync`) — `mypy` is pinned in `pyproject.toml` only,
until the planned type-check CI job lands. A version bump is a deliberate
PR updating both surfaces together. Refresh with `pip install -e ".[dev]"`.
The pinned tools require Python >= 3.10 (dev tooling only; the library
itself still supports Python 3.9).
(`black`, `ruff`, `mypy`) and mirrored in `.github/workflows/lint.yml`
(all three; the sync is enforced by `TestLintWorkflowPinSync`). `mypy
diff_diff` is enforced at zero errors by the Lint workflow's Mypy job.
A version bump is a deliberate PR updating both surfaces together.
Refresh with `pip install -e ".[dev]"`. The pinned tools require
Python >= 3.10 (dev tooling only; the library itself still supports
Python 3.9).
- `[tool.ruff.lint.per-file-ignores]` entries are deliberate (trop
logger-before-imports E402, honest_did math-notation `l` E741, `__init__`
re-export F401, conftest import ordering E402). Do not "fix" those patterns
Expand Down
99 changes: 61 additions & 38 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
| Issue | Location | Origin | Effort | Priority |
|-------|----------|--------|--------|----------|
| ChangesInChanges/QDiD tutorial notebook (2x2 distributional walkthrough: QTE grid, interior range, uniform bands, CiC-vs-QDiD comparison) - deferred from the implementation PR as a documented decision. | `docs/tutorials/` | #682 | 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 |
| `practitioner_next_steps()` dedicated handler for `ChangesInChangesResults` (currently falls back to `_handle_generic`, which is safe; a dedicated handler is the established full-integration step, cf. HAD Phase 5). | `diff_diff/practitioner.py` | #682 | Quick | Low |

---
Expand Down Expand Up @@ -173,46 +174,53 @@ For survey-specific limitations (NotImplementedError paths), see the

Target: ideally < 1000 lines per module; modules ≥3000 lines are candidates for splitting,
2000-3000 are monitored, 1000-2000 are accepted as a cohesion / scope trade-off. Updated
2026-05-15.
2026-07-13.

| File | Lines | Action |
|------|-------|--------|
| `chaisemartin_dhaultfoeuille.py` | 8636 | Consider splitting (per-path / placebos / survey IF / aggregation) |
| `had_pretests.py` | 4951 | Consider splitting (Stute / Yatchew / QUG / joint pretests) |
| `had.py` | 4593 | Consider splitting (continuous / mass-point / event-study / survey paths) |
| `staggered.py` | 3963 | Consider splitting — grew through survey + aggregation features |
| `linalg.py` | 3601 | Consider splitting (vcov surfaces) only if cohesion preserved — unified backend; vcov / solver paths tightly coupled |
| `diagnostic_report.py` | 3380 | Consider splitting (per-method renderers + provenance) |
| `power.py` | 3196 | Consider splitting (power analysis + MDE + sample size) |
| `synthetic_did.py` | 2819 | Monitor — variance methods + survey paths |
| `honest_did.py` | 2785 | Monitor |
| `business_report.py` | 2653 | Monitor — per-method narrative renderers |
| `imputation.py` | 2475 | Monitor |
| `survey.py` | 2466 | Monitor — grew with Phase 6 features |
| `utils.py` | 2396 | Monitor |
| `prep_dgp.py` | 2057 | Monitor |
| `triple_diff.py` | 2053 | Monitor |
| `estimators.py` | 1991 | Acceptable |
| `two_stage.py` | 1985 | Acceptable |
| `chaisemartin_dhaultfoeuille_results.py` | 1981 | Acceptable |
| `prep.py` | 1876 | Acceptable |
| `efficient_did.py` | 1793 | Acceptable |
| `sun_abraham.py` | 1713 | Acceptable |
| `continuous_did.py` | 1682 | Acceptable |
| `results.py` | 1676 | Acceptable |
| `staggered_triple_diff.py` | 1619 | Acceptable |
| `_nprobust_port.py` | 1412 | Acceptable |
| `practitioner.py` | 1402 | Acceptable |
| `trop_global.py` | 1350 | Acceptable |
| `trop_local.py` | 1339 | Acceptable |
| `local_linear.py` | 1332 | Acceptable |
| `wooldridge.py` | 1305 | Acceptable |
| `chaisemartin_dhaultfoeuille.py` | 8812 | Consider splitting (per-path / placebos / survey IF / aggregation) |
| `linalg.py` | 5424 | Consider splitting (vcov surfaces) only if cohesion preserved — unified backend; vcov / solver paths tightly coupled |
| `staggered.py` | 4992 | Consider splitting — grew through survey + aggregation features |
| `had.py` | 4748 | Consider splitting (continuous / mass-point / event-study / survey paths) |
| `had_pretests.py` | 4664 | Consider splitting (Stute / Yatchew / QUG / joint pretests) |
| `diagnostic_report.py` | 4135 | Consider splitting (per-method renderers + provenance) |
| `spillover.py` | 3655 | Consider splitting |
| `two_stage.py` | 3512 | Consider splitting |
| `power.py` | 3488 | Consider splitting (power analysis + MDE + sample size) |
| `utils.py` | 3483 | Consider splitting |
| `synthetic_control_results.py` | 3294 | Consider splitting |
| `honest_did.py` | 3068 | Consider splitting |
| `imputation.py` | 2898 | Monitor |
| `synthetic_did.py` | 2826 | Monitor — variance methods + survey paths |
| `business_report.py` | 2728 | Monitor — per-method narrative renderers |
| `survey.py` | 2681 | Monitor — grew with Phase 6 features |
| `synthetic_control.py` | 2526 | Monitor |
| `prep_dgp.py` | 2524 | Monitor |
| `estimators.py` | 2441 | Monitor |
| `continuous_did.py` | 2431 | Monitor |
| `sun_abraham.py` | 2314 | Monitor |
| `triple_diff.py` | 2231 | Monitor |
| `wooldridge.py` | 2192 | Monitor |
| `efficient_did.py` | 2083 | Monitor |
| `chaisemartin_dhaultfoeuille_results.py` | 2004 | Monitor |
| `results.py` | 1948 | Acceptable |
| `pretrends.py` | 1879 | Acceptable |
| `prep.py` | 1878 | Acceptable |
| `efficient_did_covariates.py` | 1818 | Acceptable |
| `staggered_triple_diff.py` | 1680 | Acceptable |
| `trop_local.py` | 1662 | Acceptable |
| `lpdid.py` | 1607 | Acceptable |
| `stacked_did.py` | 1589 | Acceptable |
| `practitioner.py` | 1511 | Acceptable |
| `_nprobust_port.py` | 1425 | Acceptable |
| `bacon.py` | 1376 | Acceptable |
| `local_linear.py` | 1325 | Acceptable |
| `trop_global.py` | 1298 | Acceptable |
| `staggered_aggregation.py` | 1204 | Acceptable |
| `chaisemartin_dhaultfoeuille_bootstrap.py` | 1175 | Acceptable |
| `bacon.py` | 1144 | Acceptable |
| `pretrends.py` | 1133 | Acceptable |
| `stacked_did.py` | 1050 | Acceptable |
| `conley.py` | 1006 | Acceptable |
| `visualization/` | 4316 | Subpackage (split across 7 files) — OK |
| `conley.py` | 1140 | Acceptable |
| `trop.py` | 1026 | Acceptable |
| `profile.py` | 1001 | Acceptable |

### Standard Error Consistency

Expand All @@ -233,8 +241,23 @@ IF/GMM estimators are tracked in

### Type Annotations

Mypy reports 0 errors. All mixin `attr-defined` errors resolved via `TYPE_CHECKING`-guarded
method stubs in the bootstrap mixin classes.
`mypy diff_diff` is **enforced at zero errors** by the Lint CI workflow's Mypy job at the
pinned `mypy==2.1.0` (ungated, every PR push; pins synced with the `dev` extra via
`TestLintWorkflowPinSync`). Zero is reached with documented suppressions — tightening them
is tracked in Actionable Backlog → Testing / docs:

- Global `disable_error_code = ["arg-type", "return-value", "var-annotated", "assignment"]`
(numpy-stub compatibility, long-standing).
- Per-module override: `diff_diff.prep_dgp` disables `[index]` (seeded-DGP Optional
covariate arrays; see the comment in `pyproject.toml`).
- `matplotlib.*` / `plotly.*` use `follow_imports = "skip"` so local and CI runs see
identical `Any` surfaces regardless of what plotting stubs are installed.
- A handful of reasoned inline `# type: ignore[code]` comments, each with a why-comment;
`warn_unused_ignores = true` prevents them from going stale.

Mixin cross-class attribute access uses `TYPE_CHECKING`-guarded attribute/method stubs in
the bootstrap mixin classes; keep stubs in sync with implementations (stub drift shows up
as `[misc]` unpack-arity errors).

### Test Coverage

Expand Down
7 changes: 5 additions & 2 deletions diff_diff/bacon.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import warnings
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple

import numpy as np
import pandas as pd
Expand All @@ -21,6 +21,9 @@
from diff_diff.utils import pre_demean_norms, snap_absorbed_regressors
from diff_diff.utils import within_transform as _within_transform_util

if TYPE_CHECKING:
from diff_diff.survey import SurveyDesign


@dataclass
class Comparison2x2:
Expand Down Expand Up @@ -1253,7 +1256,7 @@ def bacon_decompose(
time: str,
first_treat: str,
weights: str = "exact",
survey_design: object = None,
survey_design: Optional["SurveyDesign"] = None,
) -> BaconDecompositionResults:
"""
Convenience function for Goodman-Bacon decomposition.
Expand Down
21 changes: 18 additions & 3 deletions diff_diff/bootstrap_chunking.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,26 @@
from __future__ import annotations

from collections import abc
from typing import Callable, Iterable, Iterator, List, Optional, Sequence, Tuple, Union
from typing import (
TYPE_CHECKING,
Callable,
Iterable,
Iterator,
List,
Optional,
Sequence,
Tuple,
Union,
)

import numpy as np

from diff_diff._backend import HAS_RUST_BACKEND, _rust_bootstrap_weights
from diff_diff.bootstrap_utils import generate_bootstrap_weights_batch_numpy

if TYPE_CHECKING:
from diff_diff.survey import ResolvedSurveyDesign

# Byte ceiling for a single ``(B, n_units)`` float64 weight block. 256 MB keeps
# the live intermediate small at millions of units while staying large enough
# that the per-block matmuls remain BLAS-efficient and chunk overhead (a handful
Expand Down Expand Up @@ -118,7 +131,7 @@ def iter_weight_blocks(

def iter_survey_multiplier_weight_blocks(
n_bootstrap: int,
resolved_survey: object,
resolved_survey: Optional["ResolvedSurveyDesign"],
weight_type: str,
rng: np.random.Generator,
*,
Expand Down Expand Up @@ -147,10 +160,12 @@ def iter_survey_multiplier_weight_blocks(
if block_size < 1:
raise ValueError(f"block_size must be >= 1, got {block_size}")

# Callers only reach the survey-multiplier path with a resolved design.
assert resolved_survey is not None
psu = getattr(resolved_survey, "psu", None)
strata = getattr(resolved_survey, "strata", None)
if psu is None:
n_psu = len(resolved_survey.weights) # type: ignore[attr-defined]
n_psu = len(resolved_survey.weights)
psu_ids = np.arange(n_psu)
else:
psu_ids = np.unique(psu)
Expand Down
6 changes: 3 additions & 3 deletions diff_diff/bootstrap_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,9 +774,9 @@ def apply_stratum_centering(
# the contribution is zero by construction). Skip to
# avoid a divide-by-zero on sqrt(n_h / 0).
continue
slc = [slice(None)] * tensor.ndim
slc[psu_axis] = mask_h
slc = tuple(slc)
slc_list: list = [slice(None)] * tensor.ndim
slc_list[psu_axis] = mask_h
slc = tuple(slc_list)
tensor[slc] -= tensor[slc].mean(axis=psu_axis, keepdims=True)
tensor[slc] *= np.sqrt(n_h / (n_h - 1))
else:
Expand Down
2 changes: 1 addition & 1 deletion diff_diff/business_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -1428,7 +1428,7 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str,
"event's post-window, so future-treated units can serve "
"as controls for earlier events)"
)
block: Dict[str, Any] = {
block = {
"parallel_trends_variant": "stacked_sub_experiment",
"no_anticipation": True,
"description": (
Expand Down
Loading
Loading