From 6118b7e76518bd544d47352b1f8d6c9c5734cbf8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 07:46:31 -0400 Subject: [PATCH] wai-quantum: the branching test pins its reference run and forces the state vector's split paths branching_runs_depend_on_neither_threads_nor_the_state_limit failed intermittently on the linux-arm64 runner on 2026-10-19 (5 runs in 20 that morning, in debug), and has not failed since. In the failing run the reference run and a variant both moved, by a few parts in 10^5 and differently, with the counts and histories unchanged; so the test could not say which run was wrong. - The reference run is now pinned by a digest of its bits (estimates, counts, shots, histories), the same on every target. A failure of the pin says the reference itself moved; a failure of a comparison alone, a variant. - From 16 qubits a run is one tree on the state vector's own threads, which since 0.6.8 split a pass only from 2^18 amplitudes a thread. So this test's 16-qubit case no longer took the split paths it ran on that morning. It now forces them, with the state vector on 2 and 4 threads (not on wasm32, which has no threads). What was checked before this, on today's main, found no defect: - the whole library suite (716 tests) under AddressSanitizer: no error; - the threaded tests under ThreadSanitizer, with the split paths forced: no race; - the whole suite 8 times in the arm64 runner's own image: all passed; - 240 forced-thread runs of the 16-qubit case in debug: all the same bits; - the last 23 CI runs of the workflow on arm64: all passed. The cause stays unproven. These tests now catch it if it recurs, and say which run moved. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/src/quantum_noisy.rs | 26 +++++++++++++++++++++++++- wai/wai-quantum/src/quantum_sv.rs | 10 ++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/wai/wai-quantum/src/quantum_noisy.rs b/wai/wai-quantum/src/quantum_noisy.rs index e55c97e1b..b395f78f7 100644 --- a/wai/wai-quantum/src/quantum_noisy.rs +++ b/wai/wai-quantum/src/quantum_noisy.rs @@ -1213,21 +1213,45 @@ mod tests { /// Threads, the state limit and the order branches run in change nothing: /// below 16 qubits (many trees) and above (one tree, replays). + /// + /// The reference run is pinned by its bits, the same on every target, so a + /// failure says whether the reference itself moved or only a variant. From + /// 16 qubits the run is one tree on the state vector's own threads, which + /// split a pass only from 2¹⁸ amplitudes a thread; the 16-qubit case forces + /// the split paths too, where it ran them before that threshold. #[test] fn branching_runs_depend_on_neither_threads_nor_the_state_limit() { + use crate::quantum_sv::with_forced_threads; let noise = NoiseModel { depol1: 0.02, depol2: 0.05, damping: 0.01, dephasing: 0.01, readout: 0.02 }; - for (n, shots) in [(6u32, 300u64), (16, 40)] { + for (n, shots, pin) in [(6u32, 300u64, 0xf1f4_2a95_7ae8_7a7e_u64), (16, 40, 0x5e90_2d74_edf2_7819)] { let g = random_circuit(n, 3, 9); let a = branching_trajectories(&g, n, &noise, &observables(n), shots, 3, 64, 1).unwrap(); + assert_eq!(run_digest(&a), pin, "n {n}: the reference run is not the pinned one: {:?}", a.expectations); for (states, threads) in [(1usize, 1usize), (2, 5), (64, 5)] { let b = branching_trajectories(&g, n, &noise, &observables(n), shots, 3, states, threads).unwrap(); // Replays redo gates, so only the work differs. assert_eq!((&b.expectations, &b.counts, b.shots, b.histories), (&a.expectations, &a.counts, a.shots, a.histories), "n {n}: {states} states, {threads} threads"); assert!(b.gate_applications >= a.gate_applications); } + // (A target without threads has no split paths to force.) + if n >= 16 && !cfg!(target_arch = "wasm32") { + for (states, sv_threads) in [(1usize, 2usize), (64, 4)] { + let b = with_forced_threads(Some(sv_threads), || branching_trajectories(&g, n, &noise, &observables(n), shots, 3, states, 1).unwrap()); + assert_eq!(run_digest(&b), pin, "n {n}: {states} states, the state vector on {sv_threads} threads: {:?}", b.expectations); + } + } } } + /// A run's estimates, counts, shots and histories, as one FNV-1a digest of + /// their bits. + fn run_digest(r: &BranchingRun) -> u64 { + let mut v: Vec = r.expectations.iter().flat_map(|e| [e.mean.to_bits(), e.stderr.to_bits()]).collect(); + v.extend(r.counts.iter().flat_map(|&(x, c)| [x, c])); + v.extend([r.shots, r.histories]); + v.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &w| (h ^ w).wrapping_mul(0x0000_0100_0000_01b3)) + } + /// With rare errors most shots share the error-free history. #[test] fn rare_errors_share_the_work() { diff --git a/wai/wai-quantum/src/quantum_sv.rs b/wai/wai-quantum/src/quantum_sv.rs index 148865f54..00e2b3d62 100644 --- a/wai/wai-quantum/src/quantum_sv.rs +++ b/wai/wai-quantum/src/quantum_sv.rs @@ -287,6 +287,16 @@ thread_local! { static ONE_THREAD: core::cell::Cell = const { core::cell::Cell::new(false) }; } +/// Tests: run `f` with this thread's passes split `threads` ways (`None`: as +/// the machine would), so callers' tests can take the split paths at small sizes. +#[cfg(test)] +pub(crate) fn with_forced_threads(threads: Option, f: impl FnOnce() -> R) -> R { + let before = FORCE_THREADS.with(|t| t.replace(threads)); + let r = f(); + FORCE_THREADS.with(|t| t.set(before)); + r +} + /// Run `f` with this thread's state-vector kernels on this thread alone. For /// callers that run many small states in parallel; the result is the same /// bits either way. -- 2.54.0 (Apple Git-157)