diff --git a/.github/workflows/julia-release.yml b/.github/workflows/julia-release.yml index 113a9548e..0985da6ff 100644 --- a/.github/workflows/julia-release.yml +++ b/.github/workflows/julia-release.yml @@ -16,6 +16,9 @@ permissions: env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes + CARGO_HTTP_MULTIPLEXING: "false" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + CARGO_NET_RETRY: "10" on: push: diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 34ce638ec..6615d6f60 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -5,6 +5,9 @@ permissions: env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes + CARGO_HTTP_MULTIPLEXING: "false" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + CARGO_NET_RETRY: "10" # Must match pecos_build::cmake::CMAKE_VERSION. The MWPF decoder feature # uses highs-sys, which needs cmake; the wheel build installs this version # via `pecos install cmake` and points highs-sys's cmake-rs at it via $CMAKE. diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index eebabcc52..ac4af0e36 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -8,6 +8,9 @@ env: RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 PYTHONUTF8: 1 + CARGO_HTTP_MULTIPLEXING: "false" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + CARGO_NET_RETRY: "10" LLVM_VERSION: "14.0.6" # Force the MWPF decoder feature on in CI so we exercise the cmake-dependent # build path and catch regressions. GitHub-hosted runners ship cmake. diff --git a/julia/PECOS.jl/src/Decoder.jl b/julia/PECOS.jl/src/Decoder.jl index e028cf8ab..2a46ffcb4 100644 --- a/julia/PECOS.jl/src/Decoder.jl +++ b/julia/PECOS.jl/src/Decoder.jl @@ -44,11 +44,17 @@ export AbstractDecoder, DecodingResult, decode, check_count, bit_count Result of a decoding operation. """ struct DecodingResult - """Decoded observable/correction vector.""" + """ + Decoded observable/correction vector. + """ observable::Vector{UInt8} - """Weight/cost of the solution.""" + """ + Weight/cost of the solution. + """ weight::Float64 - """Whether the decoder converged (nothing = unknown).""" + """ + Whether the decoder converged (nothing = unknown). + """ converged::Union{Bool,Nothing} end diff --git a/julia/PECOS.jl/src/Simulator.jl b/julia/PECOS.jl/src/Simulator.jl index 18016e502..7ca8549cb 100644 --- a/julia/PECOS.jl/src/Simulator.jl +++ b/julia/PECOS.jl/src/Simulator.jl @@ -55,9 +55,13 @@ export sz!, h!, cx!, mz, reset!, rx!, rz!, rzz! Result of a Z-basis measurement. """ struct MeasurementResult - """Measurement outcome: false = |0>, true = |1>.""" + """ + Measurement outcome: false = |0>, true = |1>. + """ outcome::Bool - """Whether the outcome was deterministic.""" + """ + Whether the outcome was deterministic. + """ is_deterministic::Bool end diff --git a/python/pecos-rslib/src/pecos_random_bindings.rs b/python/pecos-rslib/src/pecos_random_bindings.rs index 29ba1f923..3442ddb21 100644 --- a/python/pecos-rslib/src/pecos_random_bindings.rs +++ b/python/pecos-rslib/src/pecos_random_bindings.rs @@ -40,6 +40,11 @@ impl RngPcg { pub fn srandom(&mut self, seq: u64) { PCGRandom::pcg32_srandom_r(&mut self.global_state, 42_u64, seq); } + + #[must_use] + pub fn clone(&self) -> RngPcg { + *self + } } #[cfg(test)] diff --git a/python/quantum-pecos/src/pecos/engines/cvm/rng_model.py b/python/quantum-pecos/src/pecos/engines/cvm/rng_model.py index 5ce26db4d..ba06542b3 100644 --- a/python/quantum-pecos/src/pecos/engines/cvm/rng_model.py +++ b/python/quantum-pecos/src/pecos/engines/cvm/rng_model.py @@ -24,10 +24,12 @@ def __init__( ) -> None: """Constructs an RNGModel object.""" self.shot_id = shot_id - self.current_bound = current_bound + self.current_bound = self._normalize_bound(current_bound) self.count = 0 + self._draw_bound_runs: list[tuple[int, int]] = [] self.pcg = RngPcg() - self.seed = self.set_seed(seed) + self._replay_base_pcg = self.pcg.clone() + self.set_seed(seed) def __str__(self) -> str: """Returns the str representation of the model.""" @@ -35,16 +37,47 @@ def __str__(self) -> str: def set_seed(self, seed: int) -> None: """Setting the seed for generating random numbers.""" + self._require_non_negative("seed", seed) self.seed = seed self.pcg.srandom(seed) + self.count = 0 + self._draw_bound_runs = [] + self._replay_base_pcg = self.pcg.clone() + + def start_shot(self, shot_id: int) -> None: + """Reset shot-local replay state while preserving the current stream position.""" + self.shot_id = shot_id + self.count = 0 + self._draw_bound_runs = [] + self._replay_base_pcg = self.pcg.clone() - def set_bound(self, bound: int) -> None: + def set_bound(self, bound: int | None) -> None: """Setting the current bound for generating random numbers.""" - self.current_bound = bound + self.current_bound = self._normalize_bound(bound) + + @staticmethod + def _require_non_negative(name: str, value: int) -> None: + """Raise a clear error when an RNG parameter is negative.""" + if value < 0: + error_msg = f"RNG {name} must be non-negative: got {value}" + raise ValueError(error_msg) + + @classmethod + def _normalize_bound(cls, bound: int | None) -> int: + """Normalize optional bounds to the unbounded sentinel used by the RNG model.""" + if bound is None: + return 0 + cls._require_non_negative("bound", bound) + return bound def rng_random(self) -> int: """Generating a random number and keeping track of how many we have generated.""" rng_num = self.pcg.random() if self.current_bound == 0 else self.pcg.boundedrand(self.current_bound) + if self._draw_bound_runs and self._draw_bound_runs[-1][0] == self.current_bound: + bound, run_length = self._draw_bound_runs[-1] + self._draw_bound_runs[-1] = (bound, run_length + 1) + else: + self._draw_bound_runs.append((self.current_bound, 1)) self.count += 1 return rng_num @@ -53,27 +86,65 @@ def set_index(self, index: int) -> None: The number after from the stream will be the idx of interest. """ + self._require_non_negative("index", index) if self.count > index: error_msg = f"RNGindex({index}) cannot move backward: current stream index is {self.count}" raise ValueError(error_msg) while self.count < index: self.rng_random() - def extract_val(self, param: str, output: dict) -> int: + def set_relative_index(self, delta: int) -> None: + """Move relative to the current random-number stream index.""" + target_index = self.count + delta + if target_index < 0: + error_msg = ( + f"RNGadvance({delta}) cannot move before the start of the stream: " + f"current stream index is {self.count}" + ) + raise ValueError(error_msg) + + if delta < 0: + bound_runs = list(self._draw_bound_runs) + active_bound = self.current_bound + self.pcg = self._replay_base_pcg.clone() + self.count = 0 + self._draw_bound_runs = [] + + remaining = target_index + for historical_bound, run_length in bound_runs: + if remaining <= 0: + break + self.current_bound = historical_bound + replay_count = min(run_length, remaining) + for _ in range(replay_count): + self.rng_random() + remaining -= replay_count + + self.current_bound = active_bound + else: + while self.count < target_index: + self.rng_random() + + def extract_val(self, param: str | int, output: dict) -> int: """Responsible for extracting the value of interest depending on the type of the parameter being passed in.""" - if param.isdigit(): - val = int(param) - elif "[" in param: + if isinstance(param, int): + return param + + try: + return int(param) + except (TypeError, ValueError): + pass + + if "[" in param: idx_creg = param.split("[") creg = output[idx_creg[0]] idx = int(idx_creg[-1][:-1]) - val = int(creg[idx]) - elif param == "JOB_shotnum": - val = self.shot_id - else: - reg = output[param] - val = int(reg) - return val + return int(creg[idx]) + if param == "JOB_shotnum": + return self.shot_id + + reg = output[param] + return int(reg) def eval_func(self, params: dict, output: dict) -> None: """Calling the appropriate functions dependent on RNG Function call passed in.""" @@ -90,6 +161,10 @@ def eval_func(self, params: dict, output: dict) -> None: index_var = params.get("args")[0] index = self.extract_val(index_var, output) self.set_index(index) + elif func_name == "RNGadvance": + delta_var = params.get("args")[0] + delta = self.extract_val(delta_var, output) + self.set_relative_index(delta) elif func_name == "RNGnum": creg_name = params.get("assign_vars")[0] creg = output[creg_name] diff --git a/python/quantum-pecos/src/pecos/engines/hybrid_engine_old.py b/python/quantum-pecos/src/pecos/engines/hybrid_engine_old.py index 97d5c42bf..322634093 100644 --- a/python/quantum-pecos/src/pecos/engines/hybrid_engine_old.py +++ b/python/quantum-pecos/src/pecos/engines/hybrid_engine_old.py @@ -141,8 +141,7 @@ def run( # -------------------- self.generate_errors = True error_circuits = error_gen.start(circuit, error_params) - self.rng_model.count = 0 - self.rng_model.shot_id = shot_id + self.rng_model.start_shot(shot_id) # run through the circuits... # --------------------------- diff --git a/python/quantum-pecos/tests/pecos/unit/test_rng.py b/python/quantum-pecos/tests/pecos/unit/test_rng.py index 7c49c0986..a3d4d9663 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_rng.py +++ b/python/quantum-pecos/tests/pecos/unit/test_rng.py @@ -3,15 +3,31 @@ import sys import pecos as pc +import pytest from pecos.engines.cvm.rng_model import RNGModel +def draw_at_index(seed: int, index: int) -> int: + """Return the next random value after consuming ``index`` values from a seeded stream.""" + rng = RNGModel(shot_id=0) + rng.set_seed(seed) + rng.set_index(index) + return rng.rng_random() + + def test_set_seed() -> None: """Verifies that a seed is set properly for our RNG model.""" rng = RNGModel(shot_id=0) seed = 42 rng.set_seed(seed) assert rng.seed == seed + assert rng.count == 0 + + +def test_init_normalizes_none_bound_to_unbounded() -> None: + """Verifies ``None`` uses the unbounded sentinel instead of leaking into bounded draws.""" + rng = RNGModel(shot_id=0, current_bound=None) + assert rng.current_bound == 0 def test_random_number() -> None: @@ -56,6 +72,120 @@ def test_set_idx() -> None: assert rng.count == idx +def test_relative_advance_forward() -> None: + """Verifies that a forward relative advance lands on the expected stream position.""" + rng = RNGModel(shot_id=0) + rng.set_seed(42) + + rng.set_relative_index(5) + + assert rng.count == 5 + assert rng.rng_random() == draw_at_index(42, 5) + + +def test_relative_advance_backward() -> None: + """Verifies that a backward relative advance reconstructs the stream from the seed.""" + rng = RNGModel(shot_id=0) + rng.set_seed(42) + rng.set_relative_index(6) + + rng.eval_func({"func": "RNGadvance", "args": ["-3"]}, {}) + + assert rng.count == 3 + assert rng.rng_random() == draw_at_index(42, 3) + + +def test_relative_advance_backward_past_start_raises() -> None: + """Verifies rewinds cannot move before the start of the stream.""" + rng = RNGModel(shot_id=0) + rng.set_seed(42) + rng.set_relative_index(2) + + with pytest.raises( + ValueError, + match=r"RNGadvance\(-3\) cannot move before the start of the stream: current stream index is 2", + ): + rng.set_relative_index(-3) + + +def test_reseed_then_advance_changes_draw_and_resets_count() -> None: + """Verifies reseeding resets the logical position used by later relative advances.""" + rng = RNGModel(shot_id=0) + rng.set_seed(42) + rng.set_relative_index(5) + advanced_val = rng.rng_random() + + rng.set_seed(42) + immediate_val = rng.rng_random() + expected_first_draw = draw_at_index(42, 0) + + assert advanced_val != immediate_val + assert immediate_val == expected_first_draw + assert rng.count == 1 + + +def test_relative_advance_keeps_count_consistent_after_draws() -> None: + """Verifies count tracks the current stream position after advances and draws.""" + rng = RNGModel(shot_id=0) + rng.set_seed(42) + + rng.set_relative_index(5) + assert rng.count == 5 + + rng.rng_random() + assert rng.count == 6 + + rng.set_relative_index(-2) + assert rng.count == 4 + assert rng.rng_random() == draw_at_index(42, 4) + + +def test_negative_values_are_rejected_for_non_advance_rng_funcs() -> None: + """Verifies only RNGadvance accepts negative numeric arguments.""" + rng = RNGModel(shot_id=0) + + with pytest.raises(ValueError, match=r"RNG seed must be non-negative: got -1"): + rng.eval_func({"func": "RNGseed", "args": ["-1"]}, {}) + + with pytest.raises(ValueError, match=r"RNG bound must be non-negative: got -1"): + rng.eval_func({"func": "RNGbound", "args": ["-1"]}, {}) + + with pytest.raises(ValueError, match=r"RNG index must be non-negative: got -1"): + rng.eval_func({"func": "RNGindex", "args": ["-1"]}, {}) + + +def test_relative_advance_backward_replays_historical_bounds() -> None: + """Verifies rewind reconstructs the stream using the original bounds history.""" + rng = RNGModel(shot_id=0) + rng.set_seed(42) + rng.set_bound(16) + rng.rng_random() + rng.set_bound(0) + expected_second_draw = rng.rng_random() + + rng.set_relative_index(-1) + + assert rng.count == 1 + assert rng.rng_random() == expected_second_draw + + +def test_start_shot_resets_replay_base_to_current_stream_position() -> None: + """Verifies rewinds in later shots replay from the shot start, not the original seed.""" + rng = RNGModel(shot_id=0) + rng.set_seed(42) + + for _ in range(4): + rng.rng_random() + + rng.start_shot(shot_id=1) + shot_draws = [rng.rng_random() for _ in range(3)] + + rng.set_relative_index(-1) + + assert rng.count == 2 + assert rng.rng_random() == shot_draws[-1] + + def test_multiple_bounded_rand() -> None: """For several randomly generated number, with a random bound, verifies that its appropriate.""" rng = RNGModel(shot_id=0)