Skip to content

TripleDifference absorbs StaggeredTripleDifference - M-013/M-064 (phase 3(b)) - #756

Merged
igerber merged 3 commits into
mainfrom
feat/v4-3b-ddd-facade
Aug 9, 2026
Merged

TripleDifference absorbs StaggeredTripleDifference - M-013/M-064 (phase 3(b))#756
igerber merged 3 commits into
mainfrom
feat/v4-3b-ddd-facade

Conversation

@igerber

@igerber igerber commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 3 of the 4.0 program collapses redundant estimator pairs into one class with two modes. TripleDifference now serves both DDD designs from one signature, mirroring the reference implementation (triplediff::ddd() serves both from one function). StaggeredTripleDifference and its SDDD alias are deprecated in 3.9, removed at 4.0.

The merge is API-only: the estimation core moved verbatim into the private diff_diff/_staggered_triple_diff_engine.py, mixed into both classes, so neither surface forks the math.

  • fit() dispatches on first_treat=. Staggered-only fit params are keyword-only, so a positionally-written staggered call cannot bind to the 2x2x2 slots; positional slots 1-8 (the deprecated time= included) are preserved byte-for-byte — nothing narrows.
  • Mixing the two designs' params raises in both directions, constructor params included. bootstrap_weights/seed/cband are accepted as inert in 2x2x2 mode (unreachable without n_bootstrap > 0, which that mode rejects); the power entry points apply the same boundary, so an estimator legal to fit() is legal to simulate. That boundary is defined once in utils and consumed by both.
  • time= is the calendar column in staggered mode (no rename warning) and the deprecated post= alias in 2x2x2 mode, resolved by dispatch order rather than value inspection.
  • cluster= raises in staggered mode, steering to n_bootstrap > 0; analytical clustered SEs are not implemented for that engine.
  • Returns StaggeredTripleDiffResults through 3.9; container unification is M-014's job at 4.0.

Two changes affect results

  1. Negative first_treat cohort values now raise on both surfaces. Such units belonged to neither the treated (g > 0) nor the never-enabled (g == 0) population — still counted in n_obs while contributing to no ATT comparison — so a fit returned a plausible finite estimate for a silently different sample (measured: overall_att 3.29438582 → 2.99470938, n_never_enabled 24 → 0, no error or warning). The +inf sentinel on the same parameter was already recoded-and-warned, so silence on negatives was a gap in an established contract. Affected fits now error instead of returning a number; 0/+inf fits are byte-for-byte unchanged.
  2. A positive enabling cohort with no eligible treated units now warns and is excluded from groups/n_groups, which previously advertised coverage the estimate did not have. Estimates unchanged — reporting only.

Both were pre-existing behaviors of the relocated engine, verified against main.

Also: anticipation validated as a non-negative integer via a shared utils.validate_anticipation, applied in the constructor and in the engine so both surfaces fail closed (the deprecated class's API shape stays frozen — construction succeeds, fit() raises, because this is an identification guard, not a signature change); pscore_trim gains the staggered range check plus a type guard (row M-142).

Ledger: M-013 shimmed, M-064 phase 5, new M-140/M-141 (fit-time aggregate=/balance_e= carve-out) and M-142; M-014/M-031/M-075/M-081 notes. Deferred work is recorded in TODO.md rather than left implicit.

Unrelated drive-by, kept because this PR already edits the file: the custom-DGP cell in 06_power_analysis.ipynb passed the deprecated time= alongside the profile's own post=, which the M-031 shim correctly rejects. Broken on main since the rename wave; CI excludes that notebook as too slow.

Methodology references (required if estimator / math changes)

  • Method name(s): Triple Difference (DDD) — 2x2x2 and staggered group-time ATT(g,t); optimal-GMM combination across comparison cohorts; influence-function SEs; multiplier bootstrap.
  • Paper / source link(s): Ortiz-Villavicencio & Sant'Anna (2025), Better Understanding Triple Differences Estimatorshttps://arxiv.org/abs/2505.09942 (Eqs. 4.1, 4.11–4.14). Companion R package triplediff. See docs/methodology/REGISTRY.md § TripleDifference — Staggered mode.
  • Any intentional deviations from the source (and why): None introduced by this PR. The estimation core moved verbatim; the pre-existing documented deviations (comparison-cohort rule following the companion R package rather than the paper's g_c > max(g,t); eligible-treated cohort mass in aggregation; group-level WIF adjustment; default overall ATT vs Eq. 4.14, available as overall_att_es) are unchanged and remain labelled in REGISTRY.md. New API decisions documented there as Notes: cluster= raising on the new surface, two result containers through 3.9, the pscore_trim tightening, the inert bootstrap satellites, the first_treat encoding contract, and degenerate-cohort reporting.

Validation

  • Tests added/updated: tests/test_v4_merge_ddd.py (new, 147 gates), tests/_capture_v4_merge_ddd_oracles.py (new — the oracle capture script, shipped for reproducibility), plus test_v4_matrix.py, test_naming_guard.py, test_base_estimator.py, test_v4_inference_policy.py.
  • Relocation evidence: numeric oracles were captured from the unmodified pre-move tree and committed before any source edit, covering every branch that moved — the DR base config and its bootstrap, all three nuisance models under covariates (_compute_pscore/_compute_or), the never-treated comparison fork, and the survey-pweight path. All reproduce bit-identically after the relocation. Parity between two callers of a relocated engine cannot detect a transcription slip in the move itself, which is why the absolute pins exist. The no-covariate lanes are deliberately not committed per-method (dr/ipw/reg coincide there, so identical literals would imply coverage they do not provide); that convergence is asserted live instead, and the covariate lanes' mutual distinctness is itself asserted.
  • Bit-exact parity between the merged staggered mode and the deprecated class across one-axis-at-a-time configs plus named interaction cells; exception parity for the combinations that raise.
  • Targeted suites pass: 3,009 tests across the DDD, staggered, survey, power, enforcement and docs gates (0 failures). mypy diff_diff clean at the CI-pinned toolchain; ruff and black --check clean.
  • Notebook evidence: 08_triple_diff.ipynb (new staggered-mode section), 16_survey_did.ipynb and 06_power_analysis.ipynb all execute end-to-end locally and are committed output-free.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

…M-064 (phase 3(b))

Phase 3 of the 4.0 program collapses redundant estimator pairs into one class
with two modes. `TripleDifference` now serves BOTH DDD designs from one
signature, mirroring the reference implementation (Ortiz-Villavicencio &
Sant'Anna's `triplediff::ddd()`, which serves both from one function).
`StaggeredTripleDifference` and its `SDDD` alias are deprecated in 3.9 and
removed at 4.0.

The merge is API-only: the estimation core moved VERBATIM into the private
`diff_diff/_staggered_triple_diff_engine.py`, mixed into both classes, so
neither surface forks the math.

Mechanics
- `fit()` dispatches on `first_treat=`. The staggered-only fit params are
  keyword-only, so a positionally-written staggered call cannot bind to the
  2x2x2 slots; positional slots 1-8 (the deprecated `time=` included) are
  preserved byte-for-byte, so nothing narrows.
- Mixing the two designs' params raises in BOTH directions, constructor params
  included. `bootstrap_weights`/`seed`/`cband` are accepted as inert in 2x2x2
  mode (unreachable without `n_bootstrap > 0`, which that mode rejects); the
  power entry points apply the SAME boundary, so an estimator that is legal to
  fit is legal to simulate.
- `time=` is the calendar column in staggered mode (no rename warning) and the
  deprecated `post=` alias in 2x2x2 mode, resolved by dispatch order.
- `cluster=` raises in staggered mode, steering to `n_bootstrap > 0`;
  analytical clustered SEs are not implemented for that engine.
- Returns `StaggeredTripleDiffResults` through 3.9; container unification is
  M-014's job at 4.0.

Verification that the relocation moved no numbers: oracles captured from the
UNMODIFIED pre-move tree and committed before any source edit
(`tests/_capture_v4_merge_ddd_oracles.py`), covering every branch that moved -
the DR base config and its bootstrap, all three nuisance models under
covariates, the never-treated comparison fork and the survey-pweight path.
All reproduce bit-identically post-relocation. Parity between the two callers
of a relocated engine cannot see a transcription slip in the move itself,
which is why the absolute pins exist.

TWO changes affect results and are called out in CHANGELOG:

1. Negative `first_treat` cohort values now RAISE on both surfaces. Such units
   belonged to neither the treated (`g > 0`) nor the never-enabled (`g == 0`)
   population: still counted in `n_obs`, contributing to no ATT comparison, so
   a fit returned a plausible finite estimate for a silently different sample
   (measured: overall_att 3.29438582 -> 2.99470938, n_never_enabled 24 -> 0).
   The `+inf` sentinel on the same parameter was already recoded-and-warned,
   so silence on negatives was a hole in an established contract. Affected
   fits now error instead of returning a number; `0`/`+inf` fits are unchanged.

2. A positive enabling cohort with no eligible treated units now warns and is
   excluded from `groups`/`n_groups`, which previously advertised coverage the
   estimate did not have. Estimates are unchanged - reporting only.

Also: `anticipation` validated as a non-negative integer via a shared
`utils.validate_anticipation`, applied in the constructor and in the engine so
both surfaces fail closed (the deprecated class's API shape stays frozen -
construction succeeds, fit raises, because this is an identification guard);
`pscore_trim` gains the staggered range check plus a type guard (row M-142);
the staggered-only constructor roster is centralized in `utils` so `fit()` and
the power guard cannot drift apart.

Ledger: M-013 shimmed, M-064 phase 5, new M-140/M-141 (fit-time
`aggregate=`/`balance_e=` carve-out) and M-142; M-014/M-031/M-075/M-081 notes.
Deferred work is recorded in TODO.md rather than left implicit.

Unrelated drive-by, kept because this PR already edits the file: the custom-DGP
cell in `06_power_analysis.ipynb` passed the deprecated `time=` alongside the
profile's own `post=`, which the M-031 shim correctly rejects. Broken on main
since the rename wave; CI excludes that notebook as too slow.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Overall assessment

⚠️ Needs changes — one unmitigated P1 parameter-interaction bug causes valid 2x2x2 power-analysis configurations to raise.

Executive summary

  • The staggered DDD engine relocation preserves the registered methodology, including group-time ATT, weighting, influence-function SEs, and bootstrap inference.
  • No incorrect control composition, variance calculation, or partial-NaN inference path was found.
  • P1: Power entry points reject explicit aggregate=None or balance_e=None, although direct fitting accepts them.
  • P2: A shared bootstrap warning incorrectly identifies staggered TripleDifference as CallawaySantAnna.
  • Documented methodology deviations and tracked seed/power limitations are informational only.
  • Runtime tests could not be executed because the review environment lacks the project’s Python dependencies; static checks and git diff --check passed.

Methodology

No unmitigated methodology defect found.

The affected method is staggered TripleDifference: group-time ATT(g,t), comparison-cohort combination, aggregation, influence-function variance, and multiplier bootstrap. Cross-checking the cited Ortiz-Villavicencio–Sant’Anna method and docs/methodology/REGISTRY.md found the existing comparison-cohort, weighting, group-WIF, and default-overall-ATT differences explicitly labeled as deviations. The new negative-cohort restriction and API boundaries are also documented.

  • Severity: P3 — informational, mitigated
  • Impact: None requiring action; these are registered deviations rather than silent methodology changes.
  • Concrete fix: None for this PR.

Code Quality

CQ-1: Bootstrap warning names the wrong estimator

diff_diff/staggered_bootstrap.py:L396

  • Severity: P2
  • Impact: When staggered TripleDifference inference fails closed, the warning says “CallawaySantAnna bootstrap,” potentially misleading diagnosis. Statistical outputs remain correctly NaN-closed.
  • Concrete fix: Derive the estimator name from the caller, such as type(self).__name__, or pass an estimator label into the mixin. Test both TripleDifference and the deprecated StaggeredTripleDifference surfaces.

Performance

No performance findings.

Maintainability

M-1: Power and fit encode different staggered-mode boundaries

diff_diff/power.py:L630-L646 rejects aggregate and balance_e based on key presence, while diff_diff/triple_diff.py:L833-L841 treats them as staggered-only only when their values are non-None.

  • Severity: P1
  • Impact: All three power entry points reject otherwise valid 2x2x2 configurations such as estimator_kwargs={"aggregate": None, "balance_e": None}. This contradicts the documented contract that power analysis and fit() share the same boundary.
  • Concrete fix: Check est_kwargs.get("aggregate") is not None and est_kwargs.get("balance_e") is not None. Continue using presence checks for genuinely mode-selecting arguments such as first_treat and unit.

Tech Debt

TD-1: Bootstrap seed provenance remains absent from results

Tracked in TODO.md and documented in the Methodology Registry.

  • Severity: P3 — informational, mitigated
  • Impact: Serialized results do not expose their bootstrap seed, although the seed is propagated correctly and reproducibly.
  • Concrete fix: None required for approval; follow the tracked work to add seed metadata during result-container unification.

Security

No security issues or accidental secrets found.

Documentation/Tests

No independent documentation defect found. The P1 boundary mismatch lacks regression coverage for explicit default-valued kwargs.

  • Severity: P1, associated with M-1
  • Impact: Existing tests allow the public power API to diverge from direct-fit semantics.
  • Concrete fix: Add cases for simulate_power, simulate_mde, and simulate_sample_size using explicit aggregate=None and balance_e=None, verifying equivalence to omitted defaults.

Path to Approval

  1. Align the power guard with TripleDifference.fit() by rejecting aggregate and balance_e only when non-None.
  2. Add explicit-None regression tests for all three power entry points.

…imator in the shared bootstrap warning

Two defects, both real, both verified by execution.

1. The power guard rejected `aggregate`/`balance_e` by KEY PRESENCE, while
   TripleDifference.fit() rejects them only when NON-None. So
   `fit(..., aggregate=None, balance_e=None)` succeeded in 2x2x2 mode while
   `estimator_kwargs={"aggregate": None}` raised at all three power entry
   points - breaking the very "legal to fit implies legal to simulate"
   boundary the guard exists to uphold. This was a gap in the earlier
   constructor-arm alignment, which left the fit-kwargs arm on presence checks.

   The two tests now differ per param because fit()'s do: `first_treat` and
   `unit` are sentinel-defaulted, so SUPPLYING them at all selects staggered
   mode (an explicit `first_treat=None` still does, then fails on a missing
   column), while `aggregate`/`balance_e` default to None and only a non-None
   value is staggered-only. Presence test for the first pair, value test for
   the second.

2. The single-PSU/degenerate-design bootstrap warning hard-coded
   "CallawaySantAnna bootstrap ...", but it lives in the mixin shared by
   CallawaySantAnna and both DDD classes - so a DDD fit failing closed pointed
   diagnosis at the wrong estimator. Now interpolates a `_BOOTSTRAP_LABEL`
   ClassVar supplied by each host; CallawaySantAnna's message is byte-identical
   to before.

   The mixin declares the attribute as ClassVar[str], not a bare annotation:
   an instance-variable declaration there makes every host's class-level
   assignment a "cannot override instance variable with class variable" [misc]
   error under the zero-error mypy gate. Note the contrast with
   `_warn_frame_offset` in the same subsystem, which IS assigned via instance
   and therefore must NOT be a ClassVar - the two have opposite correct
   answers.

Tests: both power arms pinned in both directions across simulate_power /
simulate_mde / simulate_sample_size (explicit None accepted, non-None
rejected, mode selectors rejected even as None). The bootstrap-label test
routes through a real single-PSU degenerate fit on BOTH surfaces rather than
asserting on source text - a source pin cannot see whether the interpolated
value reaches the user - and was confirmed non-vacuous by re-injecting the old
literal and observing it fail.

1164 passed; mypy clean at the CI-pinned toolchain.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: a343cc8cbf1bfdc92aa52f97910d0e19317ae332


Overall assessment

Looks good — no unmitigated P0 or P1 findings.

Executive summary

  • Both prior findings are resolved: explicit aggregate=None/balance_e=None now work across all power entry points, and bootstrap warnings name the correct estimator.
  • The relocated staggered DDD equations, weighting, influence-function SEs, and bootstrap paths remain aligned with the registered Ortiz-Villavicencio–Sant’Anna methodology.
  • Existing methodological deviations are explicitly documented in the Registry and are informational only.
  • Empty-result and NaN-inference paths fail closed appropriately.
  • Static parsing and git diff --check passed. Runtime tests were unavailable because pytest/numpy/pandas/scipy are not installed.

Methodology

Documented staggered-DDD deviations

  • Severity: P3 — informational, mitigated
  • Impact: The comparison-cohort rule, eligible-treated aggregation weights, group WIF adjustment, default overall ATT, and clustered-inference boundary differ from the paper or R in documented ways. The paper’s Eq. 4.14 summary remains available as overall_att_es.
  • Concrete fix: None. These are labeled Notes/Deviations in docs/methodology/REGISTRY.md:L3105-L3252.

No undocumented estimator, weighting, identification, or variance mismatch found.

Code Quality

No findings. The prior misleading bootstrap label is corrected at diff_diff/staggered_bootstrap.py:L401-L414.

Performance

No findings.

Maintainability

No findings. The prior fit/power boundary mismatch is corrected by separate presence- and value-based checks at diff_diff/power.py:L631-L659.

Tech Debt

Tracked staggered-DDD limitations

  • Severity: P3 — informational, mitigated
  • Impact: Post-fit aggregation, staggered-DDD power support, bootstrap-seed result provenance, and analytical clustered SEs remain deferred without introducing silent behavior.
  • Concrete fix: None required for approval; these are tracked in TODO.md:L24-L29.

Security

No security issues or accidental secrets found.

Documentation/Tests

No findings. Regression coverage now includes explicit-None power kwargs for all three entry points and estimator-specific bootstrap warnings on both DDD surfaces.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Aug 8, 2026
…eters block

The Sphinx -W build failed on three warnings in TripleDifference's docstring
(inline-literal start-string without end-string; field list ends without a
blank line; block quote ends without a blank line).

Cause: a flush-left prose paragraph sat INSIDE the numpydoc Parameters
section. napoleon parses every line there as a parameter entry, so it rewrote
the prose into bogus fields and split ``fit(..., first_treat=...)`` at the
comma, orphaning the opening backticks:

    :param The remaining parameters apply to the STAGGERED mode only:
    :param (``fit(...:
    :param first_treat=...)``). Four of them - ``control_group``:

The other two warnings cascade from that break.

Moved the two paragraphs verbatim into the existing Notes section, which is
numpydoc's home for cross-parameter discussion. No content lost; each
parameter entry is unchanged.

Verified with a real sphinx-build -W against CI's exact dependency list:
"build succeeded", zero warnings (previously Error 1). Confirmed this was a
regression rather than pre-existing - origin/main's docstring parses clean
through the same napoleon pipeline - and swept every public docstring this
branch touches for the same defect; none others.

The build also regenerates docs/api/_autosummary/*.rst; those refreshes are
deliberately NOT committed. main's stubs already omitted the Attributes rubric
for attributes that existed there, so the staleness predates this branch, and
committing locally-regenerated stubs would add sphinx-version churn unrelated
to the fix.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 46f5af33b7705b124750c87c813e3c119040d38c


Overall assessment

Looks good — no unmitigated P0 or P1 findings.

Executive summary

  • Both prior findings remain resolved: explicit None power kwargs are accepted, and bootstrap warnings name the fitted estimator.
  • Staggered DDD estimation, GMM weighting, influence-function SEs, and bootstrap inference align with the registered Ortiz-Villavicencio–Sant’Anna methodology.
  • All methodological deviations are explicitly documented and therefore informational.
  • The head-only change correctly repairs the TripleDifference numpydoc structure.
  • Static parsing passed; runtime tests were unavailable because pytest and numerical dependencies are not installed.

Methodology

Documented staggered-DDD deviations

  • Severity: P3 — informational, mitigated
  • Impact: Comparison-cohort selection, eligible-treated aggregation weights, group WIF adjustment, default overall ATT, and analytical clustering differ from the paper or R in documented ways.
  • Concrete fix: None required. See docs/methodology/REGISTRY.md:L3106-L3252. Inference consistently uses safe_inference() at diff_diff/_staggered_triple_diff_engine.py:L466-L470 and subsequent aggregation paths.

No undocumented estimator, weighting, identification, variance, or SE mismatch found.

Code Quality

No findings. The estimator-specific bootstrap warning is correctly implemented at diff_diff/staggered_bootstrap.py:L401-L414.

Performance

No findings.

Maintainability

No findings. Fit and power mode detection share the same constructor-boundary helper, while explicit None aggregation arguments are handled correctly at diff_diff/power.py:L645-L668.

Tech Debt

Tracked staggered-DDD limitations

  • Severity: P3 — informational, mitigated
  • Impact: Post-fit aggregation, staggered-DDD power, seed provenance, and analytical clustered SEs remain deferred.
  • Concrete fix: None required for approval; tracked in TODO.md:L24-L29.

Security

No security issues or accidental secrets found.

Documentation/Tests

No findings. The latest docstring change places cross-parameter discussion in the Notes section at diff_diff/triple_diff.py:L551-L580, preserving valid numpydoc structure. Regression coverage includes both previously reported issues in tests/test_v4_merge_ddd.py.

@igerber
igerber merged commit c2941ca into main Aug 9, 2026
36 checks passed
@igerber
igerber deleted the feat/v4-3b-ddd-facade branch August 9, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant