@@ -3467,7 +3467,62 @@ def test_none_anticipation_raises_targeted_value_error(self):
34673467
34683468
34693469class TestSpilloverDiDEventStudyBackwardCompat :
3470- """event_study=False reproduces Wave B SE bit-identity (no behavioral drift)."""
3470+ """event_study=False reproduces Wave B SE bit-identity (no behavioral drift).
3471+
3472+ The golden values below were captured against the Wave C event_study=False
3473+ path on `generate_butts_nonstaggered_dgp(seed=42)`. Since Wave C does not
3474+ modify the aggregate (Wave B) stage-2 design, fit, or extraction logic,
3475+ these values ARE the Wave B numerics. Any future drift on this PIN
3476+ indicates an accidental change to the aggregate path.
3477+ """
3478+
3479+ # PR #456 R3 golden capture (event_study=False on the seed-42 fixture).
3480+ _WAVE_B_GOLDEN_ATT = - 0.08620379515400438
3481+ _WAVE_B_GOLDEN_SE = 0.017812406263278957
3482+ _WAVE_B_GOLDEN_RING_INNER_COEF = - 0.0371780776943839
3483+ _WAVE_B_GOLDEN_RING_INNER_SE = 0.008298917907045593
3484+ _WAVE_B_GOLDEN_RING_OUTER_COEF = - 0.009441319618178406
3485+ _WAVE_B_GOLDEN_RING_OUTER_SE = 0.015538307675860204
3486+
3487+ def test_event_study_false_matches_wave_b_golden (self ):
3488+ """Pre-Wave-C golden parity (not just determinism): pin att/se on a
3489+ deterministic DGP and assert bit-identical reproduction. Strengthened
3490+ per PR #456 R3 review — the previous determinism check (fit twice on
3491+ the current code path) did not actually anchor against a pre-Wave-C
3492+ baseline."""
3493+ df = generate_butts_nonstaggered_dgp (seed = 42 )
3494+ est = SpilloverDiD (
3495+ rings = [0.0 , 50.0 , 200.0 ],
3496+ d_bar = 200.0 ,
3497+ conley_coords = ("lat" , "lon" ),
3498+ event_study = False ,
3499+ )
3500+ import warnings as _w
3501+
3502+ with _w .catch_warnings ():
3503+ _w .simplefilter ("ignore" , UserWarning )
3504+ res = est .fit (df , outcome = "y" , unit = "unit" , time = "time" , treatment = "D" )
3505+ # Scalar att/se must match the pre-Wave-C golden at machine precision.
3506+ assert res .att == self ._WAVE_B_GOLDEN_ATT , (
3507+ f"event_study=False att drift: got { res .att !r} , "
3508+ f"expected { self ._WAVE_B_GOLDEN_ATT !r} "
3509+ )
3510+ assert res .se == self ._WAVE_B_GOLDEN_SE , (
3511+ f"event_study=False se drift: got { res .se !r} , " f"expected { self ._WAVE_B_GOLDEN_SE !r} "
3512+ )
3513+ # Per-ring entries must also match.
3514+ inner = res .spillover_effects .loc ["[0, 50)" ]
3515+ assert inner ["coef" ] == self ._WAVE_B_GOLDEN_RING_INNER_COEF , (
3516+ f"inner ring coef drift: got { inner ['coef' ]!r} , "
3517+ f"expected { self ._WAVE_B_GOLDEN_RING_INNER_COEF !r} "
3518+ )
3519+ assert inner ["se" ] == self ._WAVE_B_GOLDEN_RING_INNER_SE , (
3520+ f"inner ring se drift: got { inner ['se' ]!r} , "
3521+ f"expected { self ._WAVE_B_GOLDEN_RING_INNER_SE !r} "
3522+ )
3523+ outer = res .spillover_effects .loc ["[50, 200]" ]
3524+ assert outer ["coef" ] == self ._WAVE_B_GOLDEN_RING_OUTER_COEF
3525+ assert outer ["se" ] == self ._WAVE_B_GOLDEN_RING_OUTER_SE
34713526
34723527 def test_event_study_false_bit_identical_to_wave_b_fixture (self ):
34733528 df = generate_butts_nonstaggered_dgp (seed = 42 )
@@ -3489,7 +3544,7 @@ def test_event_study_false_bit_identical_to_wave_b_fixture(self):
34893544 _w .simplefilter ("ignore" , UserWarning )
34903545 res_a = est_a .fit (df , outcome = "y" , unit = "unit" , time = "time" , treatment = "D" )
34913546 res_b = est_b .fit (df , outcome = "y" , unit = "unit" , time = "time" , treatment = "D" )
3492- # Bit-identical (deterministic fit ).
3547+ # Determinism guard (the golden parity check above pins the actual values ).
34933548 assert res_a .att == res_b .att
34943549 assert res_a .se == res_b .se
34953550
@@ -3528,6 +3583,51 @@ def tau_fn(k):
35283583 f"{ len (tau_k_estimates [k ])} seeds"
35293584 )
35303585
3586+ def test_per_ring_event_time_delta_jk_recovery (self ):
3587+ """PR #456 R3 fix: also verify per-(ring, event-time) `delta_jk`
3588+ recovery — not just `tau_k`. REGISTRY says Wave C covers `delta_jk`
3589+ recovery; this test backs that claim.
3590+
3591+ DGP places all near-controls in ring 0 (one-cohort-one-cluster), so
3592+ only ring 0 cells fire; outer rings emit NaN coefs with n_obs=0
3593+ (rectangular schema).
3594+ """
3595+
3596+ def delta_fn (j , k ):
3597+ # Mild profile in ring 0: k=0 → -0.04; k=1 → -0.035; k=2 → -0.03.
3598+ return - 0.04 + 0.005 * k
3599+
3600+ delta_k_estimates = {k : [] for k in [0 , 1 , 2 ]}
3601+
3602+ for s in range (50 ):
3603+ df = generate_butts_staggered_dgp (
3604+ seed = s ,
3605+ tau_per_event_time = lambda k : - 0.07 ,
3606+ delta_per_ring_per_event_time = delta_fn ,
3607+ )
3608+ try :
3609+ res = _fit_event_study (df , horizon_max = 2 )
3610+ except Exception :
3611+ continue
3612+ # Ring 0 corresponds to the inner ring; ring labels are like
3613+ # "[0, 50)" depending on rings passed. Iterate by position.
3614+ ring_labels = res .spillover_effects .index .get_level_values ("ring" ).unique ()
3615+ inner_ring = ring_labels [0 ]
3616+ for k in delta_k_estimates :
3617+ key = (inner_ring , k )
3618+ if key in res .spillover_effects .index :
3619+ val = res .spillover_effects .loc [key , "coef" ]
3620+ if np .isfinite (val ):
3621+ delta_k_estimates [k ].append (val )
3622+
3623+ for k , target in [(0 , - 0.04 ), (1 , - 0.035 ), (2 , - 0.03 )]:
3624+ mean_est = np .mean (delta_k_estimates [k ])
3625+ assert abs (mean_est - target ) < 0.025 , (
3626+ f"delta_jk recovery: k={ k } target={ target :.4f} , "
3627+ f"mean_est={ mean_est :.4f} over { len (delta_k_estimates [k ])} seeds "
3628+ f"(tolerance 0.025)"
3629+ )
3630+
35313631
35323632class TestSpilloverDiDEventStudyPlaceboPretrends :
35333633 """On a no-pre-trend DGP, pre-treatment coefs have nominal Type I rate."""
0 commit comments