diff --git a/Cargo.lock b/Cargo.lock index 84ea97efc..b7e251a9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1365,6 +1365,7 @@ dependencies = [ "ppvm-tableau", "ppvm-tableau-sum", "pyo3", + "rand 0.10.3", ] [[package]] @@ -1420,7 +1421,7 @@ dependencies = [ "ppvm-pauli-sum", "ppvm-pauli-word", "ppvm-traits", - "rand 0.10.1", + "rand 0.10.3", "rayon", "smallvec", ] @@ -1440,7 +1441,7 @@ dependencies = [ "ppvm-pauli-word", "ppvm-tableau", "ppvm-traits", - "rand 0.10.1", + "rand 0.10.3", "rayon", "smallvec", ] @@ -1482,6 +1483,7 @@ dependencies = [ "num", "ppvm-pauli-sum", "ppvm-tableau", + "rand 0.10.3", "rayon", "smallvec", "vihaco", @@ -1645,9 +1647,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.1", diff --git a/crates/ppvm-python-native/Cargo.toml b/crates/ppvm-python-native/Cargo.toml index 7d948575b..aaf14b584 100644 --- a/crates/ppvm-python-native/Cargo.toml +++ b/crates/ppvm-python-native/Cargo.toml @@ -25,3 +25,4 @@ pyo3 = { version = "0.29.0", features = [ "multiple-pymethods", "abi3-py310", ] } +rand = "0.10.3" diff --git a/crates/ppvm-python-native/src/interface_tableau.rs b/crates/ppvm-python-native/src/interface_tableau.rs index 8c7f50044..52706c802 100644 --- a/crates/ppvm-python-native/src/interface_tableau.rs +++ b/crates/ppvm-python-native/src/interface_tableau.rs @@ -6,6 +6,7 @@ use paste::paste; use ppvm_tableau::prelude::*; use pyo3::prelude::*; use pyo3::types::{PyByteArray, PyComplex, PyDict}; +use rand::{RngExt, SeedableRng, rngs::SmallRng}; pub(crate) fn measurement_to_u8(m: Option) -> u8 { match m { @@ -318,10 +319,9 @@ macro_rules! create_interface { /// /// Shots run in parallel on rayon's global thread pool (GIL /// released), falling back to serial for small batches. Shot `i` - /// is seeded with `seed.wrapping_add(i)` when `seed` is given - /// (wrapping mod 2⁶⁴), so results are reproducible and - /// independent of the thread count; set the `RAYON_NUM_THREADS` - /// environment variable to control the pool size. + /// is seeded with a scrambled `seed` plus `i`, so results are + /// reproducible, thread-count independent, and nearby seeds give + /// unrelated shots. Set `RAYON_NUM_THREADS` to control the pool size. /// /// Returns the outcome codes (0/1/2 = zero/one/lost) as one flat, /// shot-major `bytearray` plus its `(num_shots, n_measurements)` shape. @@ -346,7 +346,9 @@ macro_rules! create_interface { Some(s) => GeneralizedTableau::<$type, $indexType>::new_with_seed( n_qubits, min_abs_coeff, - s.wrapping_add(i as u64), + SmallRng::seed_from_u64(s) + .random::() + .wrapping_add(i as u64), ), None => GeneralizedTableau::<$type, $indexType>::new( n_qubits, diff --git a/crates/ppvm-stim/src/executor.rs b/crates/ppvm-stim/src/executor.rs index 7254786e6..02eb535a5 100644 --- a/crates/ppvm-stim/src/executor.rs +++ b/crates/ppvm-stim/src/executor.rs @@ -157,7 +157,8 @@ where /// `make_tableau(i)`. /// /// The shot index lets callers derive a deterministic per-shot seed (e.g. -/// `seed + i`) so results are independent of evaluation order — the same +/// `base.wrapping_add(i)` with `base = SmallRng::seed_from_u64(seed).random()`, +/// not plain `seed + i`) so results are independent of evaluation order — the same /// factory then yields identical results from `sample_parallel` (when the `rayon` feature is enabled). pub fn sample_serial( program: &ExtendedProgram, diff --git a/crates/ppvm-vihaco/Cargo.toml b/crates/ppvm-vihaco/Cargo.toml index f3621aa38..40602edcf 100644 --- a/crates/ppvm-vihaco/Cargo.toml +++ b/crates/ppvm-vihaco/Cargo.toml @@ -25,3 +25,4 @@ vihaco-parser = "0.4.1" vihaco-parser-derive = "0.4.1" vihaco-circuit-isa = { version = "0.1.0", path = "../vihaco-circuit-isa" } ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } +rand = "0.10.3" diff --git a/crates/ppvm-vihaco/src/shots.rs b/crates/ppvm-vihaco/src/shots.rs index a0580b6f7..0c31d1a4e 100644 --- a/crates/ppvm-vihaco/src/shots.rs +++ b/crates/ppvm-vihaco/src/shots.rs @@ -12,6 +12,7 @@ use crate::PPVMModule; use crate::composite::PPVM; use crate::measurements::MeasurementResult; +use rand::{RngExt, SeedableRng, rngs::SmallRng}; /// One shot's full output: the measurement record and the trace-instruction /// record. Either may be empty depending on what the program emits. @@ -27,10 +28,15 @@ pub const PARALLEL_SHOT_THRESHOLD: usize = 128; /// Per-shot seed derived from the base seed and the shot index, so every shot /// gets a distinct RNG stream (a shared seed would make all shots identical). -/// Depends only on `(base, index)`, so serial and parallel runs are bit-for-bit -/// identical for a given seed regardless of thread count. +/// The base is scrambled first so nearby seeds give unrelated shots. Depends +/// only on `(base, index)`, so serial and parallel runs are identical. +#[inline] fn shot_seed(base: Option, index: usize) -> Option { - base.map(|b| b.wrapping_add(index as u64)) + base.map(|b| { + SmallRng::seed_from_u64(b) + .random::() + .wrapping_add(index as u64) + }) } /// Run a single shot on a fresh machine and return both records. @@ -178,6 +184,19 @@ mod tests { ); } + #[test] + fn nearby_seeds_do_not_share_shots() { + // With `seed + i`, seed 8 was seed 7 shifted by one shot. + let m = module(RANDOM); + let a = run_shots_serial(&m, 128, Some(7)).unwrap(); + let b = run_shots_serial(&m, 128, Some(8)).unwrap(); + assert_ne!( + a[1..], + b[..127], + "seed 8 reproduced seed 7's shots shifted by one" + ); + } + #[cfg(feature = "rayon")] #[test] fn serial_and_parallel_match_for_same_seed() { diff --git a/docs/examples/stim_program.py b/docs/examples/stim_program.py index 9cf35e306..f2d256a14 100644 --- a/docs/examples/stim_program.py +++ b/docs/examples/stim_program.py @@ -23,6 +23,6 @@ # Multi-shot sampling. The two measurement outcomes are correlated on every shot. shots = sample_stim(prog, n_qubits=2, num_shots=16, seed=0) print(f"sampled {len(shots)} shots") # → sampled 16 shots -print("first shot:", shots[0]) # → first shot: [, ] +print("first shot:", shots[0]) # → first shot: [, ] all_correlated = all(s[0] == s[1] for s in shots) print("all shots correlated:", all_correlated) # → all shots correlated: True diff --git a/docs/examples/stim_sampling.py b/docs/examples/stim_sampling.py index b78933a3d..63d080d6f 100644 --- a/docs/examples/stim_sampling.py +++ b/docs/examples/stim_sampling.py @@ -26,8 +26,8 @@ patterns = Counter(tuple(int(r) for r in shot) for shot in shots) for pattern, count in sorted(patterns.items()): print(f"{pattern}: {count}") -# → (0, 0): 102 -# → (1, 1): 98 +# → (0, 0): 111 +# → (1, 1): 89 # A GHZ state should only produce (0, 0) or (1, 1) — never (0, 1) or (1, 0). correlated_fraction = sum(c for p, c in patterns.items() if p[0] == p[1]) / len(shots) diff --git a/ppvm-python/src/ppvm/generalized_tableau.py b/ppvm-python/src/ppvm/generalized_tableau.py index 321dcf346..f127c63f6 100644 --- a/ppvm-python/src/ppvm/generalized_tableau.py +++ b/ppvm-python/src/ppvm/generalized_tableau.py @@ -439,9 +439,10 @@ def sample( Shots run in parallel across CPU cores (the GIL is released during sampling), with a serial fallback for small batches. When ``seed`` is - given (it must fit in an unsigned 64-bit integer), shot ``i`` uses - ``(seed + i) % 2**64`` (wrapping ``u64`` arithmetic), so results are - reproducible and independent of the number of threads. Set the + given (it must fit in an unsigned 64-bit integer), each shot's seed is + derived deterministically from ``seed`` and the shot index, so results + are reproducible and independent of the number of threads, and nearby + seeds (``seed`` and ``seed + 1``) give unrelated shots. Set the ``RAYON_NUM_THREADS`` environment variable before the first call to control the pool size (it defaults to the number of logical cores). diff --git a/ppvm-python/test/generalized_tableau/test_stim.py b/ppvm-python/test/generalized_tableau/test_stim.py index d9c9c313d..ca80e121f 100644 --- a/ppvm-python/test/generalized_tableau/test_stim.py +++ b/ppvm-python/test/generalized_tableau/test_stim.py @@ -255,6 +255,22 @@ def test_generalized_tableau_sample_classmethod_equivalent(): assert a == b +def test_sample_nearby_seeds_do_not_share_shots(): + # With `seed + i`, seed 8 was seed 7 shifted by one shot. + n = 32 + qubits = " ".join(map(str, range(n))) + prog = StimProgram.parse(f"H {qubits}\nM {qubits}") + + def shots(seed): + res = sample_stim(prog, n_qubits=n, num_shots=1000, seed=seed) + return [tuple(int(v) for v in shot) for shot in res] + + a, b = shots(7), shots(8) + assert b[:-1] != a[1:] + # Chance collisions of 32-bit shots are rare (~2e-4). + assert len(set(a) & set(b)) < 5 + + def test_sample_stim_zero_shots_returns_empty(): prog = StimProgram.parse("X 0\nM 0") assert sample_stim(prog, n_qubits=1, num_shots=0) == [] diff --git a/skills/ppvm-usage/SKILL.md b/skills/ppvm-usage/SKILL.md index 11caf188a..4c290a72f 100644 --- a/skills/ppvm-usage/SKILL.md +++ b/skills/ppvm-usage/SKILL.md @@ -224,7 +224,9 @@ let prog = parse_extended(stim_src)?; // Multi-shot: pass a factory closure to `sample` — it reuses the parsed // program. The closure receives the shot index `i`; derive a per-shot seed -// from it (e.g. `new_with_seed(.., base.wrapping_add(i as u64))`) for reproducible runs. +// from a scrambled base, not plain `seed + i`: +// `base = SmallRng::seed_from_u64(seed).random::()`, then +// `new_with_seed(.., base.wrapping_add(i as u64))`. let shots = sample(&prog, 10_000, |_i| { GeneralizedTableau::<_, usize, _>::new(n_qubits, 1e-10) })?;