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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
docstring/REGISTRY/tutorial wording was corrected accordingly, and the old
`test_coarser_partition_more_conservative` (whose DGP made the ordering an exact
equality) was replaced by an identity pin + a genuine unbalanced-divergence test.
- **Post-fit `aggregate('event_study')`/`aggregate('group')` now work on bootstrapped
CallawaySantAnna fits** ([M-020] notes amendment; retires the TODO bootstrap
re-aggregation row). The recompute levels REPLAY the fit-time multiplier bootstrap
from a fit-retained `BootstrapReplaySpec` (the RNG state captured at weight-stream
construction, plus the run parameters BY VALUE): percentile se/CI and the sup-t
simultaneous band match a fit-time `fit(aggregate=...)` aggregation to
floating-point reassociation (`assert_allclose`, ~1 ULP — never bit-identity; the
discrete percentile p-value is a count statistic compared at `2/n_bootstrap`), the
container publishes no analytical provenance (`vcov`/`df` cleared), and
`balance_e=` composes. Properties: `seed=None` fits replay (the state is captured
by value); pickled results replay; post-fit `set_params`/attribute mutation of the
estimator cannot alter the replay. Caveats: each replaying call regenerates the
full weight stream and re-runs the fused perturbation GEMM over the per-cell
and per-event-time influence columns — O(n_bootstrap x n_units x (n_gt +
n_event_times)) FLOPs per call, no memoization by the aggregate()
immutability design; the replay re-runs the fit-time warning sites,
so warnings like the low-`n_bootstrap` notice can re-fire (the relay levels
'simple'/'total' stay silent as before); and the spec is stamped with the
weight-generation backend — an artifact unpickled under the OTHER backend
(`DIFF_DIFF_BACKEND` flip, missing Rust extension, another machine) fails closed
with a refit message rather than silently regenerating a different bootstrap
realization (stratified/single-PSU survey and census-FPC generation is
backend-independent and stays portable). Pre-replay legacy pickles fail closed
with a refit message. Ripples: `DiagnosticReport`'s ES-gated checks now RUN on
bootstrapped plain CS fits (parallel trends via the Bonferroni fallback,
pretrends-power/sensitivity via the diagonal-covariance fallback, replay warnings
republished per section), and `practitioner_next_steps` advises the post-fit
route on bootstrapped CS fits instead of the deprecated fit-time kwarg. The
sibling estimators' (EfficientDiD/ImputationDiD/TwoStageDiD/ContinuousDiD)
bootstrapped recompute gates are unchanged.

## [3.9.1] - 2026-08-17

Expand Down
5 changes: 2 additions & 3 deletions TODO.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions diff_diff/_staggered_triple_diff_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ def _run_multiplier_bootstrap(
unit: Optional[str] = None,
precomputed: Any = None,
cband: bool = True,
*,
_replay_bitgen_state: Optional[Dict[str, Any]] = None,
) -> Any: ...

def _fit_staggered_core(
Expand Down
31 changes: 27 additions & 4 deletions diff_diff/aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,11 @@ class AggregationKit:
``cband_crit_value`` is ``None`` both when bands were disabled and
when no aggregation ran, so it cannot distinguish the two.
bootstrap : AggregationKit.BootstrapReplaySpec or None
Value-bound bootstrap replay description; ``None`` on analytical fits.
Value-bound bootstrap replay description. Populated on
CallawaySantAnna bootstrapped fits (the recompute levels replay the
fit-time multiplier bootstrap from it); ``None`` on analytical fits
and on pre-replay legacy artifacts (whose bootstrapped recompute
levels fail closed with a refit message).
"""

bookkeeping: Dict[str, Any]
Expand All @@ -574,9 +578,27 @@ class BootstrapReplaySpec:
``set_params(n_bootstrap=...)`` silently changes - and can truncate - the
replayed stream.

This records the generator state plus the parameters BY VALUE and rebuilds
the stream through a module-level factory, which replays bit-identically,
pickles, and is immune to later mutation of the estimator.
This records the generator state plus the parameters BY VALUE, which
pickles and is immune to later mutation of the estimator. Two usage
modes:

- ``rebuild()`` reconstructs the plain unit-level stream via
``iter_weight_blocks`` (it does NOT cover the survey/FPC/PSU-expansion
branches).
- CallawaySantAnna's post-fit replay is STATE-ONLY: it consumes
``bitgen_state``/``n_bootstrap``/``weight_type``/``backend`` and lets
``_run_multiplier_bootstrap`` re-derive the generation branch from the
kit bookkeeping - one branch-selection implementation, no drift.

``backend`` records the weight-generation backend identity at capture
(``"rust"``/``"numpy"`` per
:func:`diff_diff.bootstrap_chunking.effective_weight_backend`, or
``"portable"`` for provably backend-independent generation branches).
The Rust and NumPy generators produce DIFFERENT draws from the same
bit-generator state, so a replay under a different backend must FAIL
CLOSED rather than silently regenerate another realization. ``None``
means unknown and also fails closed - a permissive default on a safety
discriminator would let a future constructor silently bypass the guard.
"""

bitgen_state: Dict[str, Any]
Expand All @@ -585,6 +607,7 @@ class BootstrapReplaySpec:
weight_type: str
block_size: Optional[int] = None
expand_index: Optional[np.ndarray] = None
backend: Optional[str] = None

def rebuild(self) -> Any:
"""Reconstruct the replayable weight stream."""
Expand Down
16 changes: 16 additions & 0 deletions diff_diff/bootstrap_chunking.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@
_TARGET_BLOCK_BYTES = 256 * 1024 * 1024


def effective_weight_backend() -> str:
"""The weight-generation backend :func:`iter_weight_blocks` would use NOW.

Returns ``"rust"`` exactly when the generator branch below does — the
predicate must stay identical to :func:`iter_weight_blocks`'s own
``rust_gen`` resolution. The two backends produce DIFFERENT draws from
the same bit-generator state (Rust draws one base seed and row-seeds
Xoshiro absolutely; the NumPy fallback consumes the PCG64 stream
directly), so a captured RNG state replays bit-identically only within
one backend. Post-fit bootstrap replay (CallawaySantAnna's
``BootstrapReplaySpec``) stamps this value at fit and fails closed on a
mismatch rather than silently regenerating a different realization.
"""
return "rust" if (HAS_RUST_BACKEND and _rust_bootstrap_weights is not None) else "numpy"


def compute_block_size(
n_units: int, n_bootstrap: int, target_bytes: int = _TARGET_BLOCK_BYTES
) -> int:
Expand Down
12 changes: 9 additions & 3 deletions diff_diff/diagnostic_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
recompute — for ImputationDiD a panel-backed recompute, for TwoStageDiD
a fresh Stage-2 OLS + GMM sandwich over the retained frame; used only
when the raw ``event_study_effects`` field is absent, and failing
closed to an explicit skip on bootstrapped / kit-less fits), or
closed to an explicit skip on kit-less/legacy-pickle fits, the sibling
estimators' bootstrap gates, and backend-mismatched CS bootstrap
replays — bootstrapped CS fits themselves derive successfully via the
percentile-bootstrap replay), or
produced by an existing diff-diff utility. May call
``check_parallel_trends`` / ``BaconDecomposition`` /
``EfficientDiD.hausman_pretest`` when the caller supplies the panel +
Expand Down Expand Up @@ -840,8 +843,11 @@ def _resolve_event_study_surface(
surface = candidate
surface_dict = _surface_to_event_study_dict(candidate)
except Exception as exc: # noqa: BLE001 — fail-soft by design:
# expected failures are NotImplementedError (bootstrap
# gates, pretrends+replicate) and ValueError (missing kit),
# expected failures are NotImplementedError (the sibling
# estimators' bootstrap gates, pretrends+replicate, CS
# legacy-pickle/backend-mismatch replay refusals — a
# bootstrapped CS fit itself now derives via the replay)
# and ValueError (missing kit),
# but the surface builder can raise bare TypeError and this
# resolver runs on the applicable_checks path with no outer
# guard; an escaped exception would hard-fail the report.
Expand Down
30 changes: 18 additions & 12 deletions diff_diff/guides/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ cs.fit(
```python
from diff_diff import CallawaySantAnna, plot_event_study

# NOTE: no n_bootstrap here - post-fit aggregate() is ANALYTICAL-ONLY and
# recompute levels raise NotImplementedError on a bootstrapped fit
# ('simple' relays the stored bootstrap inference - see below).
# NOTE: works on bootstrapped fits too - the recompute levels REPLAY the
# fit-time multiplier bootstrap from the kit-retained RNG state
# (percentile inference; 'simple' relays the stored inference - see below).
cs = CallawaySantAnna(estimation_method="dr")
results = cs.fit(data, outcome='outcome', unit='unit', time='period',
first_treat='first_treat')
Expand All @@ -260,10 +260,13 @@ Fit-time `aggregate=` / `balance_e=` are DEPRECATED since 3.9 (removed in 4.0,
ledger rows M-020 / M-117) and emit a `FutureWarning`. The downstream consumers
all accept the post-fit container directly - `plot_event_study`,
`compute_honest_did` and `compute_pretrends_power` each take
`results.aggregate('event_study')` - so the only case still requiring the
fit-time path is **bootstrap inference**: CallawaySantAnna's `aggregate()`
fails closed on a bootstrapped fit's RECOMPUTE levels ('event_study'/'group') rather than substituting analytical inference for
percentile-bootstrap statistics.
`results.aggregate('event_study')` - bootstrapped fits included: the
RECOMPUTE levels ('event_study'/'group') REPLAY the fit-time multiplier
bootstrap from the kit-retained RNG state (percentile se/CI/cband matching a
fit-time aggregation to floating-point reassociation; the container carries
vcov=None). Only pre-replay legacy pickles and artifacts moved across the
Rust/NumPy weight backend fail closed with a refit message - never a silent
substitution of analytical inference for percentile statistics.

```python
# Post-fit route (recommended): aggregate once, feed any consumer.
Expand Down Expand Up @@ -1711,10 +1714,11 @@ only: repeated-cross-section-routed and declared-`survey_design` fits raise
`NotImplementedError` with the reason, as do bare-`cluster=` fits whose
cohort-mass weighting diverges from the complete-case count).
`balance_e=` applies to `"event_study"` only. Raises on
`"calendar"` (CS has no calendar aggregator) and, on a bootstrapped fit, on
the recompute levels (`"event_study"`/`"group"`) rather than substituting
analytical inference for percentile-bootstrap statistics - `"simple"` and,
where supported, `"total"` relay
`"calendar"` (CS has no calendar aggregator). On a bootstrapped fit the
recompute levels (`"event_study"`/`"group"`) replay the fit-time multiplier
bootstrap (percentile inference; re-emits the fit-time bootstrap warnings;
fails closed only for pre-replay legacy pickles and cross-weight-backend
artifacts) - `"simple"` and, where supported, `"total"` relay
the stored bootstrap inference with a NaN df column (the per-level rule).

### SunAbrahamResults
Expand Down Expand Up @@ -2925,7 +2929,9 @@ BR and DR do no estimator fitting — every effect, SE, p-value, CI, and
sensitivity bound is read from the fitted result, derived from the
result's own post-fit `aggregate('event_study')` surface (a view or
retained-kit recompute, used only when the raw `event_study_effects`
field is absent; bootstrapped / kit-less fits fail closed to an
field is absent; bootstrapped CS fits derive via the percentile-bootstrap
replay, while kit-less/legacy-pickle fits, backend-mismatched replays, and
the sibling estimators' bootstrap gates fail closed to an
explicit skip), or produced by an existing diff-diff
utility (may call `check_parallel_trends`, `BaconDecomposition.fit`, or
`EfficientDiD.hausman_pretest` when the panel + column kwargs are
Expand Down
27 changes: 15 additions & 12 deletions diff_diff/guides/llms-practitioner.txt
Original file line number Diff line number Diff line change
Expand Up @@ -361,12 +361,14 @@ estimated pre-periods exist).

- For CS: pass the post-fit container - `compute_honest_did(
results.aggregate('event_study'))` - no refit needed (the fit-time
`aggregate=` route is deprecated since 3.9). EXCEPTION: on a
BOOTSTRAPPED fit (`n_bootstrap > 0`) the post-fit recompute levels
(`'event_study'`/`'group'`) raise, while `aggregate('simple')` and,
where supported, `aggregate('total')`
relay the stored bootstrap inference; use the retained fit-time
`aggregate='event_study'` for a bootstrapped event-study surface.
`aggregate=` route is deprecated since 3.9). BOOTSTRAPPED fits
(`n_bootstrap > 0`) included: the recompute levels REPLAY the
fit-time multiplier bootstrap from the kit-retained RNG state
(percentile inference; the container carries no joint covariance, so
HonestDiD uses its diagonal approximation), while
`aggregate('simple')`/`aggregate('total')` relay the stored
inference; only pre-replay legacy pickles and cross-weight-backend
artifacts fail closed with a refit message.
- For dCDH: requires `L_max >= 1` (multi-horizon mode). Bounds use placebo
estimates `DID^{pl}_l` as pre-period coefficients rather than standard
event-study pre-treatment coefficients, and use diagonal variance (no
Expand Down Expand Up @@ -446,12 +448,13 @@ print(results.aggregate('event_study', balance_e=2).to_dataframe())
# NEW in 3.10 - the estimator-owned TOTAL incremental outcome (exact
# relay C x overall; single row; panel non-survey fits only):
print(results.aggregate('total').to_dataframe())
# EXCEPTION: on a BOOTSTRAPPED fit the RECOMPUTE levels
# (event_study/group) raise on CS/EfficientDiD/ImputationDiD/TwoStageDiD
# — aggregate('simple') and, where supported, aggregate('total') relay
# the stored bootstrap inference; use the
# deprecated fit-time aggregation for a bootstrapped ES/group surface:
results = cs.fit(data, ..., aggregate='all')
# BOOTSTRAPPED fits: CS's recompute levels (event_study/group) REPLAY
# the fit-time multiplier bootstrap post-fit (percentile inference; no
# refit needed) — but they still RAISE on EfficientDiD/ImputationDiD/
# TwoStageDiD, where aggregate('simple') and, where supported,
# aggregate('total') relay the stored bootstrap inference and the
# deprecated fit-time aggregation remains the ES/group route:
results = edid.fit(data, ..., aggregate='all') # EfficientDiD et al. only
```

### For ContinuousDiD (MIXED post-fit `aggregate()`, row M-025)
Expand Down
Loading
Loading