From 8b2111cdaea3438c58d7354954bc7aac81d8cc51 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 09:06:12 -0400 Subject: [PATCH 1/3] chore(rust): bump rand 0.8 -> 0.10, rand_xoshiro 0.6 -> 0.8 These two crates are coupled through rand_core and must move together; rand_xoshiro 0.8 requires the rand_core that ships with rand 0.9+. API churn handled in bootstrap.rs (rand 0.9 renamed the gen* family): - rng.gen::() -> rng.random::() - rng.gen::() -> rng.random::() - rng.gen_range(0..6) -> rng.random_range(0..6) Bit-identity preserved: Xoshiro256PlusPlus::seed_from_u64(seed) produces the same byte stream in 0.6 and 0.8, so existing bootstrap baselines remain valid. Verified locally on macOS Accelerate: - tests/test_rust_backend.py: 95 passed (includes Rust<->Python parity tests on bootstrap weights and bootstrap-derived SE/CI quantiles) - Bootstrap-focused sweep across the broader test suite: 372 passed - 467 total passing across the directly affected surfaces Supersedes #382 (rand) and #384 (rand_xoshiro). blas-src 0.10 -> 0.14 (#386) deferred pending an ndarray compatibility check; out of scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- rust/Cargo.toml | 4 ++-- rust/src/bootstrap.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b2878c35..4d9950d4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -25,8 +25,8 @@ openblas = ["ndarray/blas"] pyo3 = "0.28" numpy = "0.28" ndarray = { version = "0.17", features = ["rayon"] } -rand = "0.8" -rand_xoshiro = "0.6" +rand = "0.10" +rand_xoshiro = "0.8" rayon = "1.8" # Pure Rust linear algebra for SVD/matrix inversion (no external deps). diff --git a/rust/src/bootstrap.rs b/rust/src/bootstrap.rs index 71d5093f..f0733f1d 100644 --- a/rust/src/bootstrap.rs +++ b/rust/src/bootstrap.rs @@ -67,7 +67,7 @@ fn generate_rademacher_batch(n_bootstrap: usize, n_units: usize, seed: u64) -> A .for_each(|(i, mut row)| { let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed.wrapping_add(i as u64)); for elem in row.iter_mut() { - *elem = if rng.gen::() { 1.0 } else { -1.0 }; + *elem = if rng.random::() { 1.0 } else { -1.0 }; } }); @@ -102,7 +102,7 @@ fn generate_mammen_batch(n_bootstrap: usize, n_units: usize, seed: u64) -> Array .for_each(|(i, mut row)| { let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed.wrapping_add(i as u64)); for elem in row.iter_mut() { - *elem = if rng.gen::() < prob_neg { + *elem = if rng.random::() < prob_neg { val_neg } else { val_pos @@ -142,7 +142,7 @@ fn generate_webb_batch(n_bootstrap: usize, n_units: usize, seed: u64) -> Array2< let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed.wrapping_add(i as u64)); for elem in row.iter_mut() { // Uniform selection: generate integer 0-5, index into weights_table - let bucket = rng.gen_range(0..6); + let bucket = rng.random_range(0..6); *elem = weights_table[bucket]; } }); From 06b3b177be43e44ad2c33305a706a1e4f5b0ef2b Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 09:36:20 -0400 Subject: [PATCH 2/3] Address PR #391 R1 review (1 P1 + 1 P2): MSRV bump + bit-identity snapshot Two fixes plus a CHANGELOG correction surfacing a finding from the review work itself: 1. P1 - MSRV bump (rust/Cargo.toml): rand_xoshiro 0.8 and rand 0.10 both declare rust-version = "1.85". Cargo.toml previously advertised 1.84, which would silently break anyone building from source on Rust 1.84 (CI uses stable so it wouldn't be caught there). Bump to "1.85" as a deliberate MSRV change, the cost of the upgrade. 2. P2 - bit-identity snapshot test (tests/test_rust_backend.py): New TestRustBackend::test_bootstrap_weights_bit_identity_snapshot pins fixed-seed (seed=42, n_bootstrap=2, n_units=4) bootstrap weights for all three weight types. Existing tests verify only shape, moments, and within-build seed-reproducibility, so a future crate upgrade that preserves the distribution while changing the draw stream would silently slip through. The snapshot test fails loudly with a localized error message instead. 3. Honest CHANGELOG entry on the Webb byte shift (NEW finding from side-by-side comparison against main): The bit-identity claim in the original PR description was correct for Rademacher and Mammen but WRONG for Webb. rand 0.9 reworked the internal algorithm for random_range (improved rejection sampling), so the same xoshiro byte stream produces different bucket selections than the old gen_range did. Distributional properties unchanged (still uniform over the 6-point support), aggregate inference converges identically, but per-seed Webb draws are now different. Verified against main on the same call: Rademacher: identical Mammen: identical Webb: DIFFERENT (verified across both rows of the snapshot) No internal test regression: 794 tests pass across all 9 webb- mentioning test files with DIFF_DIFF_BACKEND=rust. The test suite uses within-build seed-reproducibility, not cross-version baselines, so the byte shift is invisible to existing tests. Documented in CHANGELOG under [Unreleased] so users with external pinned Webb baselines aren't surprised. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 ++++ rust/Cargo.toml | 2 +- tests/test_rust_backend.py | 49 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cecad0bd..01d4cdfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- **Rust dependency upgrades**: bumped `rand` 0.8 → 0.10 and `rand_xoshiro` 0.6 → 0.8 in the Rust backend (the two crates are coupled through `rand_core` and must move together). MSRV bumped from Rust 1.84 → 1.85 to satisfy the new dependency requirements. Three call sites in `rust/src/bootstrap.rs` updated for the `rand 0.9` API rename: `gen::()` → `random::()`, `gen::()` → `random::()`, `gen_range(0..6)` → `random_range(0..6)`. **Webb wild bootstrap byte stream shifted** as a side effect: `rand 0.9` reworked the internal algorithm for `random_range` (improved rejection sampling), so `Xoshiro256PlusPlus::seed_from_u64(seed)` followed by `random_range(0..6)` consumes RNG bytes differently than the old `gen_range(0..6)` did. Distributional properties of Webb weights are unchanged (still uniform over the 6-point support); aggregate inference (SE, p-values, CI) converges to the same values for any reasonable `n_bootstrap`. Rademacher and Mammen byte streams are bit-identical to the prior release. Anyone with a saved Rust+Webb baseline pinning specific seeded results will see different numbers; the regression test suite uses within-build seed-reproducibility (not cross-version baselines) so all internal tests pass unchanged. New regression guard `TestRustBackend::test_bootstrap_weights_bit_identity_snapshot` pins fixed-seed weights for all three weight types, so any future RNG drift fails loudly with a localized error message. + ## [3.3.1] - 2026-04-25 ### Changed diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4d9950d4..40b7e33a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,7 +2,7 @@ name = "diff_diff_rust" version = "3.3.1" edition = "2021" -rust-version = "1.84" +rust-version = "1.85" description = "Rust backend for diff-diff DiD library" license = "MIT" diff --git a/tests/test_rust_backend.py b/tests/test_rust_backend.py index 48d1f0e5..7a205a15 100644 --- a/tests/test_rust_backend.py +++ b/tests/test_rust_backend.py @@ -100,6 +100,55 @@ def test_bootstrap_different_seeds(self): weights2 = generate_bootstrap_weights_batch(100, 50, "rademacher", 43) assert not np.array_equal(weights1, weights2) + def test_bootstrap_weights_bit_identity_snapshot(self): + """Pin fixed-seed bootstrap weight output byte-for-byte. + + Regression guard against silent RNG output drift across + `rand` / `rand_xoshiro` crate upgrades. Distributional moment + tests would not catch a byte shift that preserves the + distribution (e.g. `rand 0.9`'s `random_range` algorithm + change relative to `rand 0.8`'s `gen_range`). + + If this test fails after a Rust dependency bump, the byte stream + has shifted. Decide deliberately whether to accept the new + baseline (regenerate these values) or pin to a compatible + crate version. + """ + from diff_diff._rust_backend import generate_bootstrap_weights_batch + + # Captured under rand 0.10 + rand_xoshiro 0.8 with seed=42. + # Rademacher and Mammen bytes match rand 0.8 + rand_xoshiro 0.6; + # Webb bytes shifted in the rand 0.9 random_range algorithm change. + expected = { + "rademacher": np.array( + [ + [1.0, -1.0, 1.0, 1.0], + [-1.0, 1.0, 1.0, 1.0], + ] + ), + "mammen": np.array( + [ + [1.618033988749895, -0.6180339887498949, 1.618033988749895, -0.6180339887498949], + [-0.6180339887498949, -0.6180339887498949, 1.618033988749895, 1.618033988749895], + ] + ), + "webb": np.array( + [ + [1.0, -1.0, 1.224744871391589, 1.0], + [-1.0, 0.7071067811865476, 1.224744871391589, 1.224744871391589], + ] + ), + } + for weight_type, expected_arr in expected.items(): + actual = generate_bootstrap_weights_batch(2, 4, weight_type, 42) + np.testing.assert_allclose( + actual, + expected_arr, + atol=1e-14, + rtol=1e-14, + err_msg=f"{weight_type} bootstrap weights drifted from pinned baseline", + ) + # ========================================================================= # Synthetic Weight Tests # ========================================================================= From 90f526166b97eb9499bdb4bdee3046a92d70e020 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 09:44:46 -0400 Subject: [PATCH 3/3] Address PR #391 R2 review (1 P3): tighten snapshot test to assert_array_equal Reviewer noted the snapshot test docstring claims "byte-for-byte / bit identity" but the assertion was assert_allclose(atol=1e-14, rtol=1e-14), which is slightly weaker than the wording suggests. Switch to np.testing.assert_array_equal so the assertion strength matches the stated contract. Snapshot values are either exact f64 (Rademacher = +/-1.0) or computed once via correctly-rounded IEEE 754 sqrt in Rust (Mammen's golden-ratio constants, Webb's sqrt(N/2) constants), so cross-platform bit-equality holds on conformant hardware. Inline comment added explaining the safety argument. If a runner ever fails this test on a hardware/sqrt drift basis (very unlikely on x86_64 / aarch64 / ARM64 Windows), we'd learn something useful about that runner before deciding to soften the check. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_rust_backend.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_rust_backend.py b/tests/test_rust_backend.py index 7a205a15..1aa64b20 100644 --- a/tests/test_rust_backend.py +++ b/tests/test_rust_backend.py @@ -141,11 +141,13 @@ def test_bootstrap_weights_bit_identity_snapshot(self): } for weight_type, expected_arr in expected.items(): actual = generate_bootstrap_weights_batch(2, 4, weight_type, 42) - np.testing.assert_allclose( + # Strict bit-identity: the snapshot values are either exact + # (Rademacher = +/-1.0) or computed once via correctly-rounded + # IEEE 754 sqrt in Rust (Mammen, Webb), so cross-platform + # bit-equality holds on conformant hardware. + np.testing.assert_array_equal( actual, expected_arr, - atol=1e-14, - rtol=1e-14, err_msg=f"{weight_type} bootstrap weights drifted from pinned baseline", )